Skip to content

Commit 8f8206a

Browse files
committed
fix: propagate job tags to ECS tasks so cost allocation reaches compute
Without propagateTags=True on submit_job, tags on the Batch job object never reach the underlying ECS task, so Cost Explorer attribution by project/run tags misses the actual EC2/ECS spend. Set propagateTags alongside tags whenever the merged tag set is non-empty. Also note in the tags setting help text that ecs:TagResource may be required depending on account tagging-authorization settings.
1 parent 47e8027 commit 8f8206a

4 files changed

Lines changed: 82 additions & 4 deletions

File tree

docs/further.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,13 @@ inherit the run-specific tags. AWS Batch allows at most 50 tags per job;
200200
malformed pairs (missing `=` or an empty key) raise an error at submission
201201
time.
202202

203+
When tags are present the plugin also sets `propagateTags=True` on
204+
`submit_job` so that the tags reach the underlying ECS task. Without this,
205+
tags are visible on the Batch job object but absent from the ECS task that
206+
incurs the actual EC2/ECS spend, making them invisible in Cost Explorer.
207+
Depending on your account's ECS tag-authorization settings, `ecs:TagResource`
208+
may be required on the executor role for propagation to succeed.
209+
203210
# Pre-existing Job Definitions
204211

205212
By default the plugin registers a fresh AWS Batch job definition for every job

snakemake_executor_plugin_aws_batch/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ class ExecutorSettings(ExecutorSettingsBase):
5656
default=None,
5757
metadata={
5858
"help": (
59-
"The tags that should be applied to all of the batch tasks,"
60-
"of the form KEY=VALUE"
59+
"The tags that should be applied to all of the batch tasks, "
60+
"of the form KEY=VALUE. Tags are propagated to the underlying ECS "
61+
"tasks so that cost-allocation tags reach the actual compute spend "
62+
"in Cost Explorer. Note: ecs:TagResource may be required on the "
63+
"executor role depending on account ECS tagging-authorization settings."
6164
),
6265
"env_var": False,
6366
"required": False,

snakemake_executor_plugin_aws_batch/batch_job_builder.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,14 +535,19 @@ def _submit_with_preexisting_definition(self, job_definition: str) -> dict:
535535
tags = self._build_job_tags()
536536
if tags:
537537
job_params["tags"] = tags
538+
# Propagate tags from the Batch job to the underlying ECS task so that
539+
# cost-allocation tags reach the actual compute layer. Without this,
540+
# tags are visible on the Batch job object but silently absent from the
541+
# ECS task that incurs the billed EC2/ECS spend.
542+
# Note: ecs:TagResource may be required on the executor role depending
543+
# on the account's ECS tag-authorization settings.
544+
job_params["propagateTags"] = True
538545

539546
# Mirror the optional kwargs from the dynamic path. The dynamic path bakes
540547
# the timeout into the registered definition; here we have no definition to
541548
# register, so the timeout travels as SubmitJob's top-level `timeout`
542549
# field, which overrides any timeout on the pre-existing definition.
543550
# Scheduling priority applies equally to pre-existing definitions.
544-
# NOTE: any future kwarg added to submit() (e.g. propagateTags) must also
545-
# be mirrored here.
546551
task_timeout = self._resolve_task_timeout()
547552
if task_timeout is not None:
548553
job_params["timeout"] = {"attemptDurationSeconds": task_timeout}
@@ -576,6 +581,13 @@ def submit(self):
576581
tags = self._build_job_tags()
577582
if tags:
578583
job_params["tags"] = tags
584+
# Propagate tags from the Batch job to the underlying ECS task so that
585+
# cost-allocation tags reach the actual compute layer. Without this,
586+
# tags are visible on the Batch job object but silently absent from the
587+
# ECS task that incurs the billed EC2/ECS spend.
588+
# Note: ecs:TagResource may be required on the executor role depending
589+
# on the account's ECS tag-authorization settings.
590+
job_params["propagateTags"] = True
579591

580592
priority = self._resolve_scheduling_priority()
581593
if priority is not None:

tests/test_batch_job_builder.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,33 @@ def test_per_rule_queue_override_passed_to_submit_job(self):
255255
_, call_args = self._run_submit(builder)
256256
assert call_args.kwargs["jobQueue"] == "override-queue"
257257

258+
def test_propagate_tags_set_when_tags_present(self):
259+
"""propagateTags=True must be included in submit_job kwargs when tags exist."""
260+
builder = _make_builder(tags={"Env": "prod"})
261+
_, call_args = self._run_submit(builder)
262+
assert _extract_propagate_tags(call_args) is True
263+
264+
def test_propagate_tags_set_when_only_env_var_tags(self):
265+
"""propagateTags=True must appear even when tags come only from the env var."""
266+
builder = _make_builder(tags=None)
267+
with patch.dict(
268+
os.environ, {SNAKEMAKE_AWS_BATCH_JOB_TAGS_ENV_VAR: "Team=data"}
269+
):
270+
_, call_args = self._run_submit(builder)
271+
assert _extract_propagate_tags(call_args) is True
272+
273+
def test_propagate_tags_absent_when_no_tags(self):
274+
"""propagateTags must not appear in submit_job kwargs when there are no tags."""
275+
builder = _make_builder(tags=None)
276+
env = {
277+
k: v
278+
for k, v in os.environ.items()
279+
if k != SNAKEMAKE_AWS_BATCH_JOB_TAGS_ENV_VAR
280+
}
281+
with patch.dict(os.environ, env, clear=True):
282+
_, call_args = self._run_submit(builder)
283+
assert _extract_propagate_tags(call_args) is None
284+
258285

259286
def _extract_tags(call_args) -> dict | None:
260287
"""Extract the 'tags' value from a mock call_args, or None if not present."""
@@ -265,6 +292,14 @@ def _extract_tags(call_args) -> dict | None:
265292
return kwargs.get("tags")
266293

267294

295+
def _extract_propagate_tags(call_args) -> bool | None:
296+
"""Extract the 'propagateTags' value from a mock call_args, or None if absent."""
297+
if call_args is None:
298+
return None
299+
kwargs = call_args.kwargs if hasattr(call_args, "kwargs") else call_args[1]
300+
return kwargs.get("propagateTags")
301+
302+
268303
# ---------------------------------------------------------------------------
269304
# Tests for _get_platform_from_queue — exception handling
270305
# ---------------------------------------------------------------------------
@@ -1181,6 +1216,27 @@ def test_tags_forwarded_in_preexisting_mode(self):
11811216
builder.submit()
11821217
call_kwargs = builder.batch_client.submit_job.call_args.kwargs
11831218
assert call_kwargs["tags"] == {"Env": "prod", "Project": "fgumi"}
1219+
# propagateTags must be mirrored here just like the dynamic path so the
1220+
# tags reach the underlying ECS task (see submit()).
1221+
assert call_kwargs["propagateTags"] is True
1222+
1223+
def test_propagate_tags_absent_in_preexisting_mode_when_no_tags(self):
1224+
"""propagateTags must not appear when there are no tags in pre-existing mode."""
1225+
builder = _make_builder_with_preexisting(job_definition="my-job-def")
1226+
builder.settings.tags = None
1227+
builder.batch_client.submit_job.return_value = {
1228+
"jobName": "snakejob-test",
1229+
"jobId": "abc-123",
1230+
}
1231+
env = {
1232+
k: v
1233+
for k, v in os.environ.items()
1234+
if k != SNAKEMAKE_AWS_BATCH_JOB_TAGS_ENV_VAR
1235+
}
1236+
with patch.dict(os.environ, env, clear=True):
1237+
builder.submit()
1238+
call_kwargs = builder.batch_client.submit_job.call_args.kwargs
1239+
assert "propagateTags" not in call_kwargs
11841240

11851241
def test_container_overrides_no_gpu_when_zero(self):
11861242
"""GPU must NOT appear in resourceRequirements when gpu == 0."""

0 commit comments

Comments
 (0)