From 4e5dd7162f9fb875f2d65dc4563d1039ad512d33 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 22:46:45 +0300 Subject: [PATCH 1/3] docs: design compose profiles support --- planning/changes/2026-07-10.04-profiles.md | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 planning/changes/2026-07-10.04-profiles.md diff --git a/planning/changes/2026-07-10.04-profiles.md b/planning/changes/2026-07-10.04-profiles.md new file mode 100644 index 0000000..b58b1da --- /dev/null +++ b/planning/changes/2026-07-10.04-profiles.md @@ -0,0 +1,85 @@ +--- +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). +--- + +# Design: compose profiles + +## Summary + +Stop hard-rejecting a service-level `profiles:` key. Add `"profiles"` to +`IGNORED_SERVICE_KEYS` in `compose2pod/parsing.py` so a profiled service is +accepted, emits `service '': ignoring 'profiles'`, and has no effect on +the emitted script. This unblocks real-world compose files that tag optional +services (debug tools, seed jobs) with profiles. + +## Motivation + +`profiles` is currently in none of the accepted sets (`SERVICE_KEYS`, +`STRUCTURAL_KEYS`, `IGNORED_SERVICE_KEYS`), so `_validate_service` raises +`unsupported key 'profiles'` and the whole document fails to convert. Any +compose file using the common profiles pattern is rejected outright. + +Ignoring `profiles` is the *faithful* mapping, not a shortcut, because +compose2pod's run set is fully determined by `--target` plus its `depends_on` +closure (`startup_order` in `graph.py`) — there is no `docker compose up`-style +"start all active services" mode. Two Compose rules make ignoring `profiles` +correct in that model: + +- Targeting a service by name auto-activates its profile (Compose: + "when you explicitly target a service ... you do not need to enable the + profile manually"). So `--target X` on a profiled `X` runs `X`, exactly as + Compose would. +- A service outside the target's closure never runs regardless of its profile; + a service inside the closure is a hard `depends_on` dependency that must run. + Either way, `profiles` changes nothing about what compose2pod emits. + +## Design + +Single change site: add `"profiles"` to `IGNORED_SERVICE_KEYS` in +`compose2pod/parsing.py`. `_validate_service` then treats it like `ports`, +`restart`, `stop_signal`, etc. — accepted, warned, never emitted. No changes to +`graph.py`, `emit.py`, `store.py`, `keys.py`, or the CLI, and no `--profile` +flag (in the single-target model it would only add ways to reject valid input, +since targeting already auto-activates). + +`profiles` is not shape-validated, consistent with the other ignored keys and +because it is never emitted (a malformed value cannot affect output). This +keeps the change to one line plus its warning, exercised by the existing +`_validate_service` machinery. + +## Non-goals + +- A `--profile` / `COMPOSE_PROFILES` activation mechanism — no observable + effect in the single-target model. +- Shape-validating `profiles` (a list of strings) — inconsistent with the other + ignored keys, and pointless for a never-emitted value. +- Selecting or running services by profile — `--target` + `depends_on` is the + authoritative run-set selector. + +## Divergence + +If an active target `depends_on` a member whose profile Compose would leave +disabled, compose2pod runs it anyway (the closure is authoritative). This +matches the user's explicit `depends_on` intent and compose2pod's "run the +target plus its dependency closure" contract; it is *more permissive* than +Compose, not a silent drop. Recorded in `architecture/supported-subset.md`. + +## Testing + +`just test-ci` at 100%: + +- A service with `profiles: [debug]` is accepted and `validate()` returns the + warning `service 'debug-tools': ignoring 'profiles'`. +- The emitted script is byte-identical with and without the `profiles` key on a + service in the target's closure (profiles has no effect on output). +- A profiled service *outside* the target's closure does not appear in the + emitted script (unchanged closure behavior). +- `--target` naming a profiled service still emits that service. + +`just lint-ci` clean and `just check-planning`. + +## Risk + +Low. The only behavioral change is that a previously-rejected key is now +accepted-and-ignored. The one semantic subtlety — running a profiled +`depends_on` member Compose might gate — is documented under Divergence. From 3948aacac5186fa984914120343e42d6ac8aa8dd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 23:15:13 +0300 Subject: [PATCH 2/3] feat: accept and ignore the service-level profiles key --- compose2pod/parsing.py | 2 +- tests/test_emit.py | 43 ++++++++++++++++++++++++++++++++++++++++++ tests/test_parsing.py | 7 +++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 8866de2..06ac705 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -11,7 +11,7 @@ SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS -IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period"} +IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty", "stop_signal", "stop_grace_period", "profiles"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} SUPPORTED_TOP_LEVEL_KEYS = {"services", "version", "name", "networks", "volumes", "secrets", "configs"} DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} diff --git a/tests/test_emit.py b/tests/test_emit.py index 3c33c8d..ac223e8 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -636,3 +636,46 @@ def test_collects_from_extra_hosts(self) -> None: def test_collects_from_ulimits(self) -> None: compose = {"services": {"app": {"image": "x", "ulimits": {"nofile": "${MAX}"}}}} assert referenced_variables(compose, self._options()) == ["MAX"] + + +class TestProfiles: + def _script(self, doc: dict, target: str = "app") -> str: + options = EmitOptions( + target=target, + ci_image="ci", + command="", + pod="test-pod", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + return emit_script(compose=doc, options=options) + + def test_profiles_key_does_not_change_the_emitted_script(self) -> None: + # Nothing in emit reads 'profiles', so a service in the closure emits + # identically with and without it. + base = {"services": {"app": {"image": "x"}}} + with_profiles = {"services": {"app": {"image": "x", "profiles": ["debug"]}}} + assert self._script(with_profiles) == self._script(base) + + def test_profiled_service_outside_closure_is_not_run(self) -> None: + # debug-tools is not in app's depends_on closure, so it never gets a + # container (its name would appear as `test-pod-debug-tools` if run). + doc = { + "services": { + "app": {"image": "x"}, + "debug-tools": {"image": "y", "profiles": ["debug"]}, + } + } + assert "test-pod-debug-tools" not in self._script(doc) + + def test_target_on_a_profiled_service_still_runs_it(self) -> None: + # Targeting a service by name runs it regardless of its profile + # (Compose auto-activates a targeted service's profile). + doc = { + "services": { + "app": {"image": "x"}, + "debug-tools": {"image": "y", "profiles": ["debug"]}, + } + } + assert "test-pod-debug-tools" in self._script(doc, target="debug-tools") diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 666abe1..1e9ed05 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -19,6 +19,13 @@ def test_stop_lifecycle_keys_are_ignored_with_warning(self) -> None: assert any("stop_signal" in w for w in warnings) assert any("stop_grace_period" in w for w in warnings) + def test_profiles_is_ignored_with_warning(self) -> None: + # profiles cannot change the --target + depends_on run set, so it is + # accepted and warned, not rejected. + svc = {"image": "x", "profiles": ["debug"]} + warnings = validate({"services": {"app": svc}}) + assert any("profiles" in w for w in warnings) + def test_non_dict_document_raises(self) -> None: for bad in (None, [], "compose", 42): with pytest.raises(UnsupportedComposeError, match="must be a mapping"): From 17ed2961f9cd09519c77223584dbefd73d5af391 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 10 Jul 2026 23:17:56 +0300 Subject: [PATCH 3/3] docs: record profiles as an ignored service key --- architecture/supported-subset.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index 115e253..7e9e7cc 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -112,10 +112,16 @@ warns (ignored, behavior-neutral inside a single pod) or raises only a mapping value can carry an `aliases` list (`_host_names`, `compose2pod/graph.py`). - **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty`, `stop_signal`, - `stop_grace_period` — meaningless or irrelevant inside a single + `stop_grace_period`, `profiles` — meaningless or irrelevant inside a single shared-namespace pod. `stop_signal`/`stop_grace_period` are inert because the script force-removes the pod (`podman pod rm -f`) and never gracefully stops a - container. + container. `profiles` is inert because compose2pod's run set is fixed by + `--target` plus its `depends_on` closure, not by profile activation: targeting + a service by name runs it regardless of its profile (as Compose does), and a + service outside the closure never runs. One divergence follows: if the target + `depends_on` a member Compose would leave in a disabled profile, compose2pod + runs it anyway (the closure is authoritative) — more permissive than Compose, + never a silent drop. - **Extension fields:** any `x-`-prefixed service key is accepted and ignored silently. - Everything else raises.