Skip to content

Commit ea43b9b

Browse files
authored
feat: accept and ignore the service-level profiles key (#32)
* docs: design compose profiles support * feat: accept and ignore the service-level profiles key * docs: record profiles as an ignored service key
1 parent 48515c4 commit ea43b9b

5 files changed

Lines changed: 144 additions & 3 deletions

File tree

architecture/supported-subset.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,16 @@ warns (ignored, behavior-neutral inside a single pod) or raises
112112
only a mapping value can carry an `aliases` list (`_host_names`,
113113
`compose2pod/graph.py`).
114114
- **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty`, `stop_signal`,
115-
`stop_grace_period` — meaningless or irrelevant inside a single
115+
`stop_grace_period`, `profiles` — meaningless or irrelevant inside a single
116116
shared-namespace pod. `stop_signal`/`stop_grace_period` are inert because the
117117
script force-removes the pod (`podman pod rm -f`) and never gracefully stops a
118-
container.
118+
container. `profiles` is inert because compose2pod's run set is fixed by
119+
`--target` plus its `depends_on` closure, not by profile activation: targeting
120+
a service by name runs it regardless of its profile (as Compose does), and a
121+
service outside the closure never runs. One divergence follows: if the target
122+
`depends_on` a member Compose would leave in a disabled profile, compose2pod
123+
runs it anyway (the closure is authoritative) — more permissive than Compose,
124+
never a silent drop.
119125
- **Extension fields:** any `x-`-prefixed service key is accepted and ignored
120126
silently.
121127
- Everything else raises.

compose2pod/parsing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212

1313
SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS
14-
IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"}
14+
IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period", "profiles"}
1515
SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"}
1616
SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes", "secrets", "configs"}
1717
DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
---
2+
summary: Accept a service-level `profiles:` key instead of hard-rejecting it, by adding it to `IGNORED_SERVICE_KEYS` — it warns and has no effect, because compose2pod's `--target` + `depends_on` closure already fully determines the run set (and targeting a service by name auto-activates its profile, matching Compose).
3+
---
4+
5+
# Design: compose profiles
6+
7+
## Summary
8+
9+
Stop hard-rejecting a service-level `profiles:` key. Add `"profiles"` to
10+
`IGNORED_SERVICE_KEYS` in `compose2pod/parsing.py` so a profiled service is
11+
accepted, emits `service '<name>': ignoring 'profiles'`, and has no effect on
12+
the emitted script. This unblocks real-world compose files that tag optional
13+
services (debug tools, seed jobs) with profiles.
14+
15+
## Motivation
16+
17+
`profiles` is currently in none of the accepted sets (`SERVICE_KEYS`,
18+
`STRUCTURAL_KEYS`, `IGNORED_SERVICE_KEYS`), so `_validate_service` raises
19+
`unsupported key 'profiles'` and the whole document fails to convert. Any
20+
compose file using the common profiles pattern is rejected outright.
21+
22+
Ignoring `profiles` is the *faithful* mapping, not a shortcut, because
23+
compose2pod's run set is fully determined by `--target` plus its `depends_on`
24+
closure (`startup_order` in `graph.py`) — there is no `docker compose up`-style
25+
"start all active services" mode. Two Compose rules make ignoring `profiles`
26+
correct in that model:
27+
28+
- Targeting a service by name auto-activates its profile (Compose:
29+
"when you explicitly target a service ... you do not need to enable the
30+
profile manually"). So `--target X` on a profiled `X` runs `X`, exactly as
31+
Compose would.
32+
- A service outside the target's closure never runs regardless of its profile;
33+
a service inside the closure is a hard `depends_on` dependency that must run.
34+
Either way, `profiles` changes nothing about what compose2pod emits.
35+
36+
## Design
37+
38+
Single change site: add `"profiles"` to `IGNORED_SERVICE_KEYS` in
39+
`compose2pod/parsing.py`. `_validate_service` then treats it like `ports`,
40+
`restart`, `stop_signal`, etc. — accepted, warned, never emitted. No changes to
41+
`graph.py`, `emit.py`, `store.py`, `keys.py`, or the CLI, and no `--profile`
42+
flag (in the single-target model it would only add ways to reject valid input,
43+
since targeting already auto-activates).
44+
45+
`profiles` is not shape-validated, consistent with the other ignored keys and
46+
because it is never emitted (a malformed value cannot affect output). This
47+
keeps the change to one line plus its warning, exercised by the existing
48+
`_validate_service` machinery.
49+
50+
## Non-goals
51+
52+
- A `--profile` / `COMPOSE_PROFILES` activation mechanism — no observable
53+
effect in the single-target model.
54+
- Shape-validating `profiles` (a list of strings) — inconsistent with the other
55+
ignored keys, and pointless for a never-emitted value.
56+
- Selecting or running services by profile — `--target` + `depends_on` is the
57+
authoritative run-set selector.
58+
59+
## Divergence
60+
61+
If an active target `depends_on` a member whose profile Compose would leave
62+
disabled, compose2pod runs it anyway (the closure is authoritative). This
63+
matches the user's explicit `depends_on` intent and compose2pod's "run the
64+
target plus its dependency closure" contract; it is *more permissive* than
65+
Compose, not a silent drop. Recorded in `architecture/supported-subset.md`.
66+
67+
## Testing
68+
69+
`just test-ci` at 100%:
70+
71+
- A service with `profiles: [debug]` is accepted and `validate()` returns the
72+
warning `service 'debug-tools': ignoring 'profiles'`.
73+
- The emitted script is byte-identical with and without the `profiles` key on a
74+
service in the target's closure (profiles has no effect on output).
75+
- A profiled service *outside* the target's closure does not appear in the
76+
emitted script (unchanged closure behavior).
77+
- `--target` naming a profiled service still emits that service.
78+
79+
`just lint-ci` clean and `just check-planning`.
80+
81+
## Risk
82+
83+
Low. The only behavioral change is that a previously-rejected key is now
84+
accepted-and-ignored. The one semantic subtlety — running a profiled
85+
`depends_on` member Compose might gate — is documented under Divergence.

tests/test_emit.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,46 @@ def test_collects_from_extra_hosts(self) -> None:
636636
def test_collects_from_ulimits(self) -> None:
637637
compose = {"services": {"app": {"image": "x", "ulimits": {"nofile": "${MAX}"}}}}
638638
assert referenced_variables(compose, self._options()) == ["MAX"]
639+
640+
641+
class TestProfiles:
642+
def _script(self, doc: dict, target: str = "app") -> str:
643+
options = EmitOptions(
644+
target=target,
645+
ci_image="ci",
646+
command="",
647+
pod="test-pod",
648+
project_dir="/proj",
649+
artifacts=[],
650+
allow_exit_codes=[],
651+
)
652+
return emit_script(compose=doc, options=options)
653+
654+
def test_profiles_key_does_not_change_the_emitted_script(self) -> None:
655+
# Nothing in emit reads 'profiles', so a service in the closure emits
656+
# identically with and without it.
657+
base = {"services": {"app": {"image": "x"}}}
658+
with_profiles = {"services": {"app": {"image": "x", "profiles": ["debug"]}}}
659+
assert self._script(with_profiles) == self._script(base)
660+
661+
def test_profiled_service_outside_closure_is_not_run(self) -> None:
662+
# debug-tools is not in app's depends_on closure, so it never gets a
663+
# container (its name would appear as `test-pod-debug-tools` if run).
664+
doc = {
665+
"services": {
666+
"app": {"image": "x"},
667+
"debug-tools": {"image": "y", "profiles": ["debug"]},
668+
}
669+
}
670+
assert "test-pod-debug-tools" not in self._script(doc)
671+
672+
def test_target_on_a_profiled_service_still_runs_it(self) -> None:
673+
# Targeting a service by name runs it regardless of its profile
674+
# (Compose auto-activates a targeted service's profile).
675+
doc = {
676+
"services": {
677+
"app": {"image": "x"},
678+
"debug-tools": {"image": "y", "profiles": ["debug"]},
679+
}
680+
}
681+
assert "test-pod-debug-tools" in self._script(doc, target="debug-tools")

tests/test_parsing.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ def test_stop_lifecycle_keys_are_ignored_with_warning(self) -> None:
1919
assert any("stop_signal" in w for w in warnings)
2020
assert any("stop_grace_period" in w for w in warnings)
2121

22+
def test_profiles_is_ignored_with_warning(self) -> None:
23+
# profiles cannot change the --target + depends_on run set, so it is
24+
# accepted and warned, not rejected.
25+
svc = {"image": "x", "profiles": ["debug"]}
26+
warnings = validate({"services": {"app": svc}})
27+
assert any("profiles" in w for w in warnings)
28+
2229
def test_non_dict_document_raises(self) -> None:
2330
for bad in (None, [], "compose", 42):
2431
with pytest.raises(UnsupportedComposeError, match="must be a mapping"):

0 commit comments

Comments
 (0)