Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
85 changes: 85 additions & 0 deletions planning/changes/2026-07-10.04-profiles.md
Original file line number Diff line number Diff line change
@@ -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 '<name>': 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.
43 changes: 43 additions & 0 deletions tests/test_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
7 changes: 7 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down