|
6 | 6 | from dataclasses import dataclass, field |
7 | 7 | from pprint import pformat |
8 | 8 | from typing import List, AsyncGenerator, Optional |
| 9 | + |
| 10 | +import boto3 |
| 11 | +from botocore.exceptions import ClientError |
| 12 | + |
9 | 13 | from snakemake_executor_plugin_aws_batch.batch_client import BatchClient |
10 | 14 | from snakemake_executor_plugin_aws_batch.batch_job_builder import BatchJobBuilder |
11 | 15 | from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo |
|
20 | 24 | from snakemake_interface_common.exceptions import WorkflowError |
21 | 25 |
|
22 | 26 |
|
| 27 | +# Batch job-queue / compute-environment `status` values that definitively |
| 28 | +# prevent jobs from running. CREATING/UPDATING are transient and must NOT fail |
| 29 | +# the preflight check (a queue mid-update is recoverable), so only these abort. |
| 30 | +FATAL_BATCH_STATUSES = frozenset({"INVALID", "DELETING", "DELETED"}) |
| 31 | + |
| 32 | + |
23 | 33 | # Optional: |
24 | 34 | # Define additional settings for your executor. |
25 | 35 | # They will occur in the Snakemake CLI as --<executor-name>-<param-name> |
@@ -158,6 +168,10 @@ def __post_init__(self): |
158 | 168 | except Exception as e: |
159 | 169 | raise WorkflowError(f"Failed to initialize AWS Batch client: {e}") from e |
160 | 170 |
|
| 171 | + # Fail fast on a definitively misconfigured queue/compute environment/role |
| 172 | + # before submitting any jobs (degrades to a no-op if state is uncertain). |
| 173 | + self._preflight_validate() |
| 174 | + |
161 | 175 | def run_job(self, job: JobExecutorInterface): |
162 | 176 | # Implement here how to run a job. |
163 | 177 | # You can access the job's resources, etc. |
@@ -329,3 +343,136 @@ def cancel_jobs(self, active_jobs: List[SubmittedJobInfo]): |
329 | 343 | # cleanup jobs |
330 | 344 | for j in active_jobs: |
331 | 345 | self.cleanup_job_resources(j) |
| 346 | + |
| 347 | + def _preflight_validate(self) -> None: |
| 348 | + """Fail fast on a definitively misconfigured queue / compute env / role. |
| 349 | +
|
| 350 | + Best-effort about *uncertainty*: a transient API error or a missing |
| 351 | + describe/iam permission degrades to a warning and the workflow proceeds. |
| 352 | + Only a confirmed-bad configuration (disabled/invalid queue or compute |
| 353 | + environment, ``maxvCpus=0``, or a non-existent job role) raises, before |
| 354 | + any job is submitted. |
| 355 | + """ |
| 356 | + problems = self._queue_problems() |
| 357 | + if problems: |
| 358 | + raise WorkflowError( |
| 359 | + "AWS Batch preflight check failed — jobs would never start: " |
| 360 | + + "; ".join(problems) |
| 361 | + + ". Check the configured --aws-batch-job-queue and its compute " |
| 362 | + "environment(s)." |
| 363 | + ) |
| 364 | + self._validate_job_role() |
| 365 | + |
| 366 | + def _queue_problems(self) -> Optional[List[str]]: |
| 367 | + """Return definitive job-queue / compute-environment misconfigurations. |
| 368 | +
|
| 369 | + Returns an empty list when everything looks healthy, a non-empty list of |
| 370 | + problem descriptions when the queue or a compute environment is in a |
| 371 | + state that would prevent jobs from ever starting (disabled/invalid, |
| 372 | + ``maxvCpus=0``), or None when the state can't be determined (no queue |
| 373 | + configured, or the queue describe itself failed — never block the |
| 374 | + workflow on a transient failure or a missing describe permission). |
| 375 | +
|
| 376 | + The queue and compute-environment lookups fail independently: a failure |
| 377 | + describing the compute environment(s) (e.g. a missing |
| 378 | + ``batch:DescribeComputeEnvironments`` permission) still returns any |
| 379 | + confirmed queue-level problems collected so far rather than discarding |
| 380 | + them, so a definitively disabled/invalid queue is never masked by an |
| 381 | + unrelated failure downstream. |
| 382 | +
|
| 383 | + Compute environments are judged collectively: AWS Batch falls back |
| 384 | + across a queue's ``computeEnvironmentOrder``, so a compute-environment |
| 385 | + problem is reported only when *every* attached environment is unusable — |
| 386 | + one healthy environment is enough for jobs to run. |
| 387 | + """ |
| 388 | + queue_arn = getattr(self.settings, "job_queue", None) |
| 389 | + if not queue_arn: |
| 390 | + return None |
| 391 | + # If we can't even describe the queue, the state is unknown — degrade to |
| 392 | + # a no-op rather than blocking the workflow on a transient failure or a |
| 393 | + # missing batch:DescribeJobQueues permission. |
| 394 | + try: |
| 395 | + queues = self.batch_client.describe_job_queues(jobQueues=[queue_arn]).get( |
| 396 | + "jobQueues", [] |
| 397 | + ) |
| 398 | + except Exception as e: |
| 399 | + self.logger.debug(f"could not check job queue: {e}") |
| 400 | + return None |
| 401 | + if not queues: |
| 402 | + return ["job queue not found"] |
| 403 | + jq = queues[0] |
| 404 | + problems: List[str] = [] |
| 405 | + if jq.get("state") != "ENABLED": |
| 406 | + problems.append(f"job queue is {jq.get('state')} (not ENABLED)") |
| 407 | + if jq.get("status") in FATAL_BATCH_STATUSES: |
| 408 | + problems.append(f"job queue status is {jq.get('status')}") |
| 409 | + ce_arns = [ |
| 410 | + o.get("computeEnvironment") for o in jq.get("computeEnvironmentOrder", []) |
| 411 | + ] |
| 412 | + ce_arns = [c for c in ce_arns if c] |
| 413 | + if ce_arns: |
| 414 | + # A failure here must NOT discard the confirmed queue-level problems |
| 415 | + # already collected above — return them instead of None. |
| 416 | + try: |
| 417 | + ces = self.batch_client.describe_compute_environments( |
| 418 | + computeEnvironments=ce_arns |
| 419 | + ).get("computeEnvironments", []) |
| 420 | + except Exception as e: |
| 421 | + self.logger.debug(f"could not check compute environment(s): {e}") |
| 422 | + return problems |
| 423 | + # AWS Batch tries a queue's compute environments in order and falls |
| 424 | + # back to the next, so jobs are only definitively blocked when EVERY |
| 425 | + # attached compute environment is unusable. Collect per-CE reasons |
| 426 | + # but surface them only when none is usable — one healthy compute |
| 427 | + # environment is enough for jobs to run. |
| 428 | + ce_reasons: List[str] = [] |
| 429 | + any_usable = False |
| 430 | + for ce in ces: |
| 431 | + name = ce.get("computeEnvironmentName", "?") |
| 432 | + reasons: List[str] = [] |
| 433 | + if ce.get("state") != "ENABLED": |
| 434 | + reasons.append(f"compute environment {name} is {ce.get('state')}") |
| 435 | + if ce.get("status") in FATAL_BATCH_STATUSES: |
| 436 | + reasons.append( |
| 437 | + f"compute environment {name} status is {ce.get('status')}" |
| 438 | + ) |
| 439 | + if (ce.get("computeResources") or {}).get("maxvCpus") == 0: |
| 440 | + reasons.append(f"compute environment {name} has maxvCpus=0") |
| 441 | + if reasons: |
| 442 | + ce_reasons.extend(reasons) |
| 443 | + else: |
| 444 | + any_usable = True |
| 445 | + if ces and not any_usable: |
| 446 | + problems.append( |
| 447 | + "no usable compute environment on the job queue: " |
| 448 | + + "; ".join(ce_reasons) |
| 449 | + ) |
| 450 | + return problems |
| 451 | + |
| 452 | + def _validate_job_role(self) -> None: |
| 453 | + """Verify the configured job role exists (best-effort; needs iam:GetRole). |
| 454 | +
|
| 455 | + A confirmed-missing role (``NoSuchEntity``) fails fast; anything else |
| 456 | + (most importantly a missing ``iam:GetRole`` permission) degrades silently |
| 457 | + so the check never blocks a workflow on uncertainty. |
| 458 | + """ |
| 459 | + role_arn = getattr(self.settings, "job_role", None) |
| 460 | + if not role_arn or "/" not in role_arn: |
| 461 | + return |
| 462 | + # GetRole takes the bare role name, not the IAM path: for |
| 463 | + # arn:aws:iam::<acct>:role/<path>/<name> the name is the final segment. |
| 464 | + role_name = role_arn.rsplit("/", 1)[-1] |
| 465 | + try: |
| 466 | + boto3.client("iam", region_name=self.settings.region).get_role( |
| 467 | + RoleName=role_name |
| 468 | + ) |
| 469 | + except ClientError as e: |
| 470 | + if e.response.get("Error", {}).get("Code") == "NoSuchEntity": |
| 471 | + raise WorkflowError( |
| 472 | + f"Configured AWS Batch job role does not exist: {role_arn}" |
| 473 | + ) from e |
| 474 | + self.logger.debug( |
| 475 | + f"could not verify job role (likely missing iam:GetRole): {e}" |
| 476 | + ) |
| 477 | + except Exception as e: |
| 478 | + self.logger.debug(f"could not verify job role: {e}") |
0 commit comments