Skip to content

Commit a07a8aa

Browse files
committed
feat: preflight-validate the job queue, compute environment, and job role
Run a best-effort validation in __post_init__, before any job is submitted, so a definitively misconfigured setup fails fast with a clear error instead of leaving jobs stuck forever: - _queue_problems(): inspect the configured job queue and its compute environment(s); report a disabled/invalid queue or compute environment, a fatal queue status (INVALID/DELETING/DELETED), or maxvCpus=0. - _preflight_validate(): raise WorkflowError on any confirmed problem, then check the job role. - _validate_job_role(): iam:GetRole existence check; NoSuchEntity fails fast, anything else (most importantly a missing iam:GetRole permission) degrades. The check is conservative about uncertainty: a transient API error, a queue mid-update, or a missing describe/iam permission degrades to a debug message and the workflow proceeds. Only a confirmed-bad configuration blocks. Documented in docs/further.md; the workflow-level mocked test short-circuits preflight the same way it short-circuits platform detection.
1 parent 47e8027 commit a07a8aa

4 files changed

Lines changed: 501 additions & 0 deletions

File tree

docs/further.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,20 @@ Task timeout and scheduling priority **are** still honored: `--aws-batch-task-ti
250250
resource) travel as `SubmitJob`'s top-level `timeout` and `schedulingPriorityOverride`
251251
fields, so they apply to pre-existing definitions just as they do to dynamically
252252
registered ones.
253+
254+
# Preflight Validation
255+
256+
At startup (before submitting any job) the executor verifies that the
257+
configured job queue is `ENABLED` and `VALID`, that its compute environment(s)
258+
are `ENABLED`/`VALID` with `maxvCpus > 0`, and — when `--aws-batch-job-role` is
259+
set and `iam:GetRole` is available — that the job role exists. A confirmed
260+
misconfiguration (a disabled/invalid queue or compute environment, `maxvCpus=0`,
261+
or a non-existent job role) fails fast with a clear error, so you don't wait for
262+
jobs that could never start.
263+
264+
The check is deliberately conservative about *uncertainty*: a transient API
265+
error, a queue mid-update (`status` `CREATING`/`UPDATING`), or a missing
266+
`batch:Describe*` / `iam:GetRole` permission is treated as a warning and the
267+
workflow proceeds. It uses the `batch:DescribeJobQueues` /
268+
`batch:DescribeComputeEnvironments` permissions the executor already needs, plus
269+
the optional `iam:GetRole` for the job-role check.

snakemake_executor_plugin_aws_batch/__init__.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
from dataclasses import dataclass, field
77
from pprint import pformat
88
from typing import List, AsyncGenerator, Optional
9+
10+
import boto3
11+
from botocore.exceptions import ClientError
12+
913
from snakemake_executor_plugin_aws_batch.batch_client import BatchClient
1014
from snakemake_executor_plugin_aws_batch.batch_job_builder import BatchJobBuilder
1115
from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
@@ -20,6 +24,12 @@
2024
from snakemake_interface_common.exceptions import WorkflowError
2125

2226

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+
2333
# Optional:
2434
# Define additional settings for your executor.
2535
# They will occur in the Snakemake CLI as --<executor-name>-<param-name>
@@ -158,6 +168,10 @@ def __post_init__(self):
158168
except Exception as e:
159169
raise WorkflowError(f"Failed to initialize AWS Batch client: {e}") from e
160170

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+
161175
def run_job(self, job: JobExecutorInterface):
162176
# Implement here how to run a job.
163177
# You can access the job's resources, etc.
@@ -329,3 +343,136 @@ def cancel_jobs(self, active_jobs: List[SubmittedJobInfo]):
329343
# cleanup jobs
330344
for j in active_jobs:
331345
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

Comments
 (0)