Skip to content

Commit cc995db

Browse files
authored
refactor: move environment into the service-key registry (#64)
Move `environment` into the SERVICE_KEYS registry as `_map("-e")`, deleting its hand-rolled validate/emit/merge. Behavior-preserving. See planning/changes/2026-07-15.17-environment-into-registry.md.
1 parent 4aa01b8 commit cc995db

8 files changed

Lines changed: 123 additions & 35 deletions

File tree

architecture/supported-subset.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ boolean mapping value (`labels: {enabled: true}`) is normalized like
269269
| `devices` | `--device` (repeated) | list of strings |
270270
| `labels` | `--label` (repeated; null value → bare `KEY`) | list or mapping |
271271
| `annotations` | `--annotation` (repeated; null value → bare `KEY`) | list or mapping |
272+
| `environment` | `-e` (repeated; null value → bare `KEY`, i.e. host passthrough) | list or mapping |
272273
| `pull_policy` | `--pull` (`if_not_present``missing`, rest verbatim) | enum: `always`/`never`/`missing`/`if_not_present` |
273274
| `ulimits` | `--ulimit` (`name=value` or `name=soft:hard`) | mapping, see below |
274275

@@ -365,12 +366,6 @@ slot (`architecture/glossary.md`).
365366
the same Docker shell-form `ENTRYPOINT` semantic, not a
366367
compose2pod-specific limitation. Use a list (exec-form) entrypoint, or
367368
none, if the override needs to actually run.
368-
- **`environment`:** list (`- KEY=value`, `- KEY`) or mapping (`KEY: value`,
369-
`KEY:`) — the same shape rule as the registry's map-shaped keys. A null
370-
mapping value means "pass `KEY` through from the host", emitted as a bare
371-
`-e KEY`, same as the list form `- KEY`. A boolean value (`DEBUG: true`)
372-
is normalized like Docker: `-e DEBUG=true`, not the Python repr `-e
373-
DEBUG=True`.
374369
- **`env_file`:** a string or list of strings. Each resolved path passes
375370
through `--project-dir` when relative, then is emitted as `--env-file`.
376371
- **`tmpfs`:** a string or list of strings, each `<path>` or
@@ -877,7 +872,7 @@ truth if this enumeration ever appears to drift:
877872

878873
- **Structural fields:** `image` (only when the service has no `build`
879874
override — otherwise the CI image is used, not the compose value),
880-
`command`, `entrypoint`, `environment`, `env_file`, `volumes`, `tmpfs`,
875+
`command`, `entrypoint`, `env_file`, `volumes`, `tmpfs`,
881876
the healthcheck `test` command, and the pod-level
882877
`dns`/`dns_search`/`dns_opt`/`sysctls`/`extra_hosts` values.
883878
- **Registry fields** whose spec wraps its value in `Expand` — every

compose2pod/emit.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from compose2pod.exceptions import UnsupportedComposeError
1111
from compose2pod.graph import depends_on, hostnames, startup_order
1212
from compose2pod.healthcheck import health_cmd, interval_seconds
13-
from compose2pod.keys import SERVICE_KEYS, Expand, Token, key_value_pairs
13+
from compose2pod.keys import SERVICE_KEYS, Expand, Token
1414
from compose2pod.parsing import validate
1515
from compose2pod.pod import pod_create_flags
1616
from compose2pod.resources import deploy_resource_flags
@@ -69,12 +69,9 @@ def _health_flags(healthcheck: dict[str, Any]) -> list[Token]:
6969
return flags
7070

7171

72-
def _env_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
73-
"""-e and --env-file flag tokens."""
72+
def _env_file_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
73+
"""--env-file flag tokens. `environment` is a SERVICE_KEYS registry key (_map("-e"))."""
7474
flags: list[Token] = []
75-
# A null environment value means "pass KEY through from the host" (bare `-e KEY`).
76-
for pair in key_value_pairs(svc.get("environment") or {}):
77-
flags += ["-e", Expand(value=str(pair))]
7875
env_files = svc.get("env_file") or []
7976
if isinstance(env_files, str):
8077
env_files = [env_files]
@@ -109,7 +106,7 @@ def _volume_flags(svc: dict[str, Any], project_dir: str) -> list[Token]:
109106
def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
110107
"""Flag tokens (unquoted) for `podman run` of one service."""
111108
flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"]
112-
flags += _env_flags(svc, project_dir)
109+
flags += _env_file_flags(svc, project_dir)
113110
flags += _volume_flags(svc, project_dir)
114111
flags += _health_flags(svc.get("healthcheck") or {})
115112
for key, spec in SERVICE_KEYS.items():

compose2pod/extends.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
from typing import Any
44

55
from compose2pod.exceptions import UnsupportedComposeError
6-
from compose2pod.keys import SERVICE_KEYS, as_list, pairs_to_mapping, split_extra_host
6+
from compose2pod.keys import SERVICE_KEYS, as_list, split_extra_host
77

88

99
# Merge policy for keys with a SERVICE_KEYS KeySpec comes from spec.merge (see
1010
# _merge below); these two sets cover only the remaining structural keys, which
1111
# have no KeySpec. Both categories obey one rule: a merge may normalize a value
1212
# only through a form that key actually has, so it can never accept a shape the
1313
# gate would reject standalone.
14-
_STRUCTURAL_MERGE_KEYS = {"environment", "extra_hosts", "healthcheck", "depends_on"}
14+
_STRUCTURAL_MERGE_KEYS = {"extra_hosts", "healthcheck", "depends_on"}
1515
_STRUCTURAL_CONCAT_KEYS = {"secrets", "configs", "volumes", "tmpfs", "env_file"}
1616

1717
# The only concat keys Compose gives a bare-string form. The gate accepts a
@@ -90,16 +90,17 @@ def _as_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa: ANN
9090
"""Normalize a structural mapping-merge key's value to a mapping.
9191
9292
List form is accepted for exactly the keys Compose defines one for --
93-
`environment`, `extra_hosts`, `depends_on` -- each through the normalizer
94-
that key's own list form actually needs. `healthcheck` has no list form, so
95-
a list is refused rather than coerced: a merge must not accept a shape the
96-
gate would reject standalone (see `_merge`).
93+
`extra_hosts`, `depends_on` -- each through the normalizer that key's own
94+
list form actually needs. `environment` used to be handled here too, but
95+
it is now a `SERVICE_KEYS` registry key (`_map("-e")`) whose own
96+
`merge=_merge_map` branch in `_merge` wins first, so this function never
97+
sees it. `healthcheck` has no list form, so a list is refused rather than
98+
coerced: a merge must not accept a shape the gate would reject standalone
99+
(see `_merge`).
97100
"""
98101
if isinstance(value, dict):
99102
return value
100103
if isinstance(value, list):
101-
if key == "environment":
102-
return pairs_to_mapping(name, key, value)
103104
if key == "extra_hosts":
104105
return _extra_hosts_to_mapping(name, value)
105106
if key == "depends_on":

compose2pod/keys.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
342342

343343

344344
SERVICE_KEYS: dict[str, KeySpec] = {
345+
"environment": _map("-e"),
345346
"user": _scalar("--user"),
346347
"working_dir": _scalar("--workdir"),
347348
"platform": _scalar("--platform"),
@@ -388,7 +389,7 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
388389
"build",
389390
"command",
390391
"entrypoint",
391-
"environment",
392+
# "environment" removed — now a SERVICE_KEYS registry key (_map("-e")).
392393
"env_file",
393394
"volumes",
394395
"tmpfs",

compose2pod/parsing.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,6 @@ def _validate_tmpfs(name: str, svc: dict[str, Any]) -> None:
493493
_validate_string_or_string_list(name, "tmpfs", svc.get("tmpfs"))
494494

495495

496-
def _validate_environment(name: str, svc: dict[str, Any]) -> None:
497-
"""Check environment is a list or mapping (a bare string would be walked as .items())."""
498-
if svc.get("environment") is not None:
499-
validate_map(name, "environment", svc["environment"])
500-
501-
502496
def _validate_env_file(name: str, svc: dict[str, Any]) -> None:
503497
"""Check env_file is a string or list of strings (emit iterates it)."""
504498
_validate_string_or_string_list(name, "env_file", svc.get("env_file"))
@@ -553,7 +547,6 @@ def _validate_service(name: str, svc: Any) -> list[str]: # noqa: ANN401 - Compo
553547
_validate_entrypoint(name, svc)
554548
_validate_command(name, svc)
555549
_validate_tmpfs(name, svc)
556-
_validate_environment(name, svc)
557550
_validate_env_file(name, svc)
558551
validate_deploy(name, svc)
559552
validate_pod_options(name, svc)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
summary: Move the `environment` service key into the SERVICE_KEYS registry as `_map("-e")`, deleting its hand-rolled validate/emit/merge across parsing/emit/extends so it gains the single-spec locality (and drift-immunity) the 29 registry keys already have.
3+
---
4+
5+
# Design: move `environment` into the service-key registry
6+
7+
## Summary
8+
9+
`environment` is handled outside `SERVICE_KEYS` today — its validate lives in
10+
`parsing._validate_environment`, its emit in the `-e` branch of `emit._env_flags`,
11+
its merge in `extends._STRUCTURAL_MERGE_KEYS` — even though it is provably
12+
`_map("-e")`: the same `validate_map`, the same `["-e", Expand(str(pair))]` emit
13+
loop, and the same `_merge_map` its registry twins `labels`/`annotations` already
14+
use. This change adds `"environment": _map("-e")` to `SERVICE_KEYS` and deletes
15+
the three hand-rolled halves, giving `environment` one spec = validate+emit+merge
16+
in one place. Behavior-preserving.
17+
18+
## Motivation
19+
20+
`environment` re-implements a registry abstraction the code already trusts for 29
21+
keys, and the hand-rolling carries the exact drift hazard the registry retires:
22+
its validate (`parsing.py`, `validate_map`) and its merge normalizer
23+
(`extends._as_mapping`) live in different modules with no binding, so the
24+
`extends` structural branch must re-derive the accepted grammar by hand to avoid
25+
laundering a shape the gate would reject (`extends.py:83-119`). The registry
26+
branch closes this by construction — it runs `spec.validate` on each side before
27+
`spec.merge` (`extends.py:145-156`). Moving `environment` in inherits that
28+
immunity for free. This is the narrow move licensed as examined-and-in-scope by
29+
`decisions/2026-07-12-reject-structural-key-registry.md` (the reopen note): a key
30+
that shares one uniform shape, not a structural-key registry.
31+
32+
## Design
33+
34+
Four source edits:
35+
36+
- **`keys.py`** — add `"environment": _map("-e")` to `SERVICE_KEYS`; remove
37+
`"environment"` from `STRUCTURAL_KEYS`.
38+
- **`parsing.py`** — delete `_validate_environment` and its call in
39+
`_validate_service`. `environment` is now validated by the existing
40+
`SERVICE_KEYS` loop (`spec.validate`, `parsing.py:560-562`). The old
41+
`if ... is not None` guard was already dead: `_reject_null_values` refuses a
42+
null `environment` earlier in the same function.
43+
- **`emit.py`** — rename `_env_flags``_env_file_flags` and drop its `-e`
44+
loop, keeping only the `--env-file` emission; update the `run_flags` call site.
45+
`environment`'s `-e` tokens now come from the `SERVICE_KEYS` loop.
46+
- **`extends.py`** — remove `"environment"` from `_STRUCTURAL_MERGE_KEYS`; it
47+
now merges via `SERVICE_KEYS["environment"].merge`.
48+
49+
Invariants held:
50+
51+
- **Gate accept-list**: `SUPPORTED_SERVICE_KEYS = set(SERVICE_KEYS) | STRUCTURAL_KEYS`
52+
`environment` only switches sides; the union is identical, so no key's
53+
accept/reject status changes.
54+
- **Emit**: `-e KEY=val` tokens are byte-identical. The `-e` flags move *after*
55+
`--env-file` in the generated `podman run` line, but podman's env precedence is
56+
by source, not command-line order — `--env` overrides `--env-file` regardless
57+
(stated in `podman-run.1`'s ENVIRONMENT precedence list) — so the container
58+
environment is unchanged, and Compose's "environment overrides env_file" still
59+
holds.
60+
- **Validate**: both the old and new paths call `validate_map`; the refusal
61+
message for a malformed `environment` is unchanged.
62+
63+
## Non-goals
64+
65+
- Moving `tmpfs`/`env_file`/`volumes``tmpfs` needs a bespoke validator
66+
(scalar-or-list vs `_list`'s list-only) and `env_file`/`volumes` need
67+
`project_dir`, which would force widening `KeySpec.emit`. Out of scope here.
68+
- Any change to `SERVICE_KEYS.emit`'s signature — `environment` fits
69+
`emit(value)` as-is.
70+
71+
## Testing
72+
73+
`just test-ci` at 100% coverage, `just lint-ci` clean, `just check-planning`.
74+
75+
- `tests/test_keys.py``SERVICE_KEYS` count +1, `STRUCTURAL_KEYS` −1, union
76+
unchanged, `SERVICE_KEYS`/`STRUCTURAL_KEYS` still disjoint; the parametrized
77+
merge test now asserts `environment` carries `merge=_merge_map`.
78+
- `tests/test_emit.py` — the positional `-e` assertions (`flags[4:6]`, etc.)
79+
shift; rewrite them to locate the `-e` pair rather than a fixed index, and keep
80+
the `env_file`-only case (now `--env-file` at the front of the env flags).
81+
- `tests/test_parsing.py` — malformed `environment` still refused with the same
82+
`validate_map` message.
83+
- `tests/test_extends.py` — add a case proving the registry branch now validates
84+
each side before merging (an extends chain whose individual side has a
85+
malformed `environment` is refused, matching `labels`/`annotations`), plus the
86+
existing valid list-form/dict-form `environment` merges re-asserted unchanged.
87+
- Conformance + integration suites — green, no behavior change.
88+
89+
## Risk
90+
91+
- **A test constructs an extends chain with a per-side-malformed `environment`
92+
that was previously accepted post-merge** (low × med): now refused earlier by
93+
the registry branch. This is the intended drift-close (identical to
94+
`labels`/`annotations` today) — grep `test_extends.py` first; if such a case
95+
exists it is re-asserted as a refusal, not a regression.
96+
- **Missed positional `-e` assertion in `test_emit.py`** (low × low): caught by
97+
`just test-ci` before push.

tests/test_emit.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ class TestRunFlags:
2828
def test_db_flags(self, chats_compose: dict) -> None:
2929
flags = run_flags("db", chats_compose["services"]["db"], "test-pod", "/builds/chats")
3030
assert flags[:4] == ["--pod", "test-pod", "--name", "test-pod-db"]
31-
assert flags[4:6] == ["-e", Expand(value="POSTGRES_PASSWORD=password")]
32-
assert flags[6:8] == ["--health-cmd", Expand(value="pg_isready -U database -d database")]
33-
assert flags[8:10] == ["--health-timeout", "5s"]
34-
assert flags[10:12] == ["--health-retries", "15"] # fix #2
31+
# environment is a registry key now, emitted after the healthcheck flags.
32+
assert flags[4:6] == ["--health-cmd", Expand(value="pg_isready -U database -d database")]
33+
assert flags[6:8] == ["--health-timeout", "5s"]
34+
assert flags[8:10] == ["--health-retries", "15"] # fix #2
35+
assert flags[10:12] == ["-e", Expand(value="POSTGRES_PASSWORD=password")]
3536

3637
def test_start_period_is_passed_through(self) -> None:
3738
svc = {"image": "x", "healthcheck": {"test": "true", "start_period": "30s"}}

tests/test_extends.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,10 @@ def test_incompatible_mapping_form_is_refused(self) -> None:
224224
"web": {"extends": {"service": "base"}, "environment": "NOT_A_MAP"},
225225
}
226226
}
227-
with pytest.raises(UnsupportedComposeError, match="cannot merge 'environment' across incompatible forms"):
227+
# environment is now a registry key: the merge validates each side first
228+
# (like labels/annotations), so a non-mapping local is refused by
229+
# validate_map before the structural "cannot merge" path is reached.
230+
with pytest.raises(UnsupportedComposeError, match="'environment' must be a list or mapping"):
228231
resolve_extends(doc)
229232

230233
def test_diamond_inheritance_resolves_base_once(self) -> None:

0 commit comments

Comments
 (0)