diff --git a/.gitignore b/.gitignore index c73839d..9968db9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Ephemeral planning scratch (executable plans are not version-controlled) +.superpowers/ + # Generic things *.pyc *~ diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md index bed0da4..0d7d442 100644 --- a/architecture/supported-subset.md +++ b/architecture/supported-subset.md @@ -19,16 +19,34 @@ warns (ignored, behavior-neutral inside a single pod) or raises ## Service keys -- **Supported:** `image`, `build`, `command`, `environment`, `env_file`, - `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`, - `container_name`, `tmpfs`. compose2pod never builds: a `build` section is - accepted but its contents (context, dockerfile, args) are not read — - `image_for` (`compose2pod/emit.py`) runs the CI image supplied via `--image` - for any service that has one. +- **Supported:** `image`, `build`, `command`, `entrypoint`, `environment`, + `env_file`, `volumes`, `healthcheck`, `depends_on`, `networks`, `hostname`, + `container_name`, `tmpfs`, `user`, `working_dir`, `group_add`, `labels`. + compose2pod never builds: a `build` section is accepted but its contents + (context, dockerfile, args) are not read — `image_for` (`compose2pod/emit.py`) + runs the CI image supplied via `--image` for any service that has one. - **`environment`:** list form (`- KEY=value`, `- KEY`) or mapping form (`KEY: value`, `KEY:`). A null mapping value (`KEY:`) means "pass `KEY` through from the host", emitted as a bare `-e KEY` exactly like the list form `- KEY`. +- **`entrypoint`:** string or list. List form is exec form; string form is + shell form (`/bin/sh -c `), mirroring `command`. Emitted as + `--entrypoint ` with the remaining tokens placed ahead of the + command after the image, so `podman run --entrypoint a IMAGE b ` + runs `a b ` -- no JSON needed. A string (shell-form) entrypoint + ignores the service `command`, matching Docker; `validate()` warns when both + are set. The target's `--command` override still applies as explicit intent, + but when the target has a string entrypoint, the override tokens land after + `-c ` and are passed positionally to `sh` as `$0`/`$1`... + rather than executed -- the same Docker shell-form `ENTRYPOINT` semantic, not + a compose2pod-specific limitation. Use a list (exec-form) entrypoint, or none, + if the override needs to actually run. +- **`user` / `working_dir`:** strings, emitted verbatim as `--user` / `--workdir`. +- **`group_add`:** a list, emitted as repeated `--group-add`. +- **`labels`:** list (`- KEY=value` / `- KEY`) or mapping (`KEY: value` / `KEY:`), + emitted as repeated `--label`. A null value means an empty label + (`--label KEY`) -- the same emitted shape as `environment`'s null but a + distinct meaning (labels have no host-passthrough). - **`tmpfs`:** a string or list of strings, each `` or `:` (e.g. `/tmp:mode=1777`), passed through verbatim as `podman run --tmpfs ` — Compose's short syntax maps directly onto diff --git a/compose2pod/emit.py b/compose2pod/emit.py index 572df82..d966b88 100644 --- a/compose2pod/emit.py +++ b/compose2pod/emit.py @@ -12,6 +12,10 @@ HEALTHY_WAIT_BUDGET_SECONDS = 120 +_SCALAR_FLAGS: dict[str, str] = {"user": "--user", "working_dir": "--workdir"} + +_LIST_FLAGS: dict[str, str] = {"group_add": "--group-add"} + @dataclasses.dataclass(frozen=True) class _Expand: @@ -40,6 +44,16 @@ def command_tokens(svc: dict[str, Any]) -> list[Token]: return [_Expand(str(token)) for token in command] +def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]: + """Service entrypoint as argv tokens; compose string form means shell form.""" + entrypoint = svc.get("entrypoint") + if entrypoint is None: + return [] + if isinstance(entrypoint, str): + return ["/bin/sh", "-c", _Expand(entrypoint)] + return [_Expand(str(token)) for token in entrypoint] + + def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None: """Add healthcheck flags to the flags list.""" cmd = health_cmd(healthcheck.get("test")) @@ -53,26 +67,31 @@ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None: flags += ["--health-retries", str(healthcheck["retries"])] -def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]: - """Flag tokens (unquoted) for `podman run` of one service.""" - flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"] - for host in hosts: - flags += ["--add-host", f"{host}:127.0.0.1"] - environment = svc.get("environment") or {} - # A null mapping value means "pass KEY through from the host" (bare `-e KEY`), - # matching Compose and the list form `- KEY`. - pairs = ( - environment - if isinstance(environment, list) - else [k if v is None else f"{k}={v}" for k, v in environment.items()] - ) - for pair in pairs: +def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]: + """Compose list/map key-value section as 'KEY=value' / 'KEY' entries. + + A null map value yields a bare 'KEY'. Meaning is caller-defined: '-e KEY' + passes the host value through; '--label KEY' sets an empty label. + """ + if isinstance(value, list): + return value + return [key if val is None else f"{key}={val}" for key, val in value.items()] + + +def _add_env_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None: + """Add -e and --env-file flags to the flags list.""" + # A null environment value means "pass KEY through from the host" (bare `-e KEY`). + for pair in _key_value_pairs(svc.get("environment") or {}): flags += ["-e", _Expand(str(pair))] env_files = svc.get("env_file") or [] if isinstance(env_files, str): env_files = [env_files] for env_file in env_files: flags += ["--env-file", _Expand(str(Path(project_dir, env_file)))] + + +def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str) -> None: + """Add -v and --tmpfs flags to the flags list.""" for volume in svc.get("volumes") or []: if ":" not in volume: # Anonymous volume: a bare container path, no host source to translate. @@ -90,8 +109,29 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec tmpfs = [tmpfs] for mount in tmpfs: flags += ["--tmpfs", _Expand(mount)] - healthcheck = svc.get("healthcheck") or {} - _add_health_flags(flags, healthcheck) + + +def _add_declarative_flags(flags: list[Token], svc: dict[str, Any]) -> None: + """Add the scalar-, list-, and label-driven flags to the flags list.""" + for key, flag in _SCALAR_FLAGS.items(): + if key in svc: + flags += [flag, _Expand(str(svc[key]))] + for key, flag in _LIST_FLAGS.items(): + for item in svc.get(key) or []: + flags += [flag, _Expand(str(item))] + for pair in _key_value_pairs(svc.get("labels") or {}): + flags += ["--label", _Expand(str(pair))] + + +def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]: + """Flag tokens (unquoted) for `podman run` of one service.""" + flags: list[Token] = ["--pod", pod, "--name", f"{pod}-{name}"] + for host in hosts: + flags += ["--add-host", f"{host}:127.0.0.1"] + _add_env_flags(flags, svc, project_dir) + _add_volume_flags(flags, svc, project_dir) + _add_health_flags(flags, svc.get("healthcheck") or {}) + _add_declarative_flags(flags, svc) return flags @@ -139,10 +179,16 @@ def _render(tokens: list[Token]) -> str: def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[Token]: svc = services[name] tokens = run_flags(name, svc, options.pod, hosts, options.project_dir) + entrypoint = entrypoint_tokens(svc) + if entrypoint: + tokens += ["--entrypoint", entrypoint[0]] tokens.append(image_for(svc, options.ci_image)) + tokens += entrypoint[1:] if name == options.target and options.command: tokens.extend(shlex.split(options.command)) - else: + elif not isinstance(svc.get("entrypoint"), str): + # A string (shell-form) entrypoint runs via `sh -c`; the service command + # is ignored, matching Docker. List/exec form appends the command. tokens.extend(command_tokens(svc)) return tokens diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index f51994f..2113e89 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -11,6 +11,7 @@ "image", "build", "command", + "entrypoint", "environment", "env_file", "volumes", @@ -20,6 +21,10 @@ "hostname", "container_name", "tmpfs", + "user", + "working_dir", + "group_add", + "labels", } IGNORED_SERVICE_KEYS = {"ports", "restart", "stdin_open", "tty"} SUPPORTED_HEALTHCHECK_KEYS = {"test", "interval", "timeout", "retries", "start_period"} @@ -54,6 +59,23 @@ def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: # volume implicitly on first reference. +def _validate_service_forms(name: str, svc: dict[str, Any]) -> None: + """Check the process/identity keys use their allowed Compose forms.""" + for key in ("user", "working_dir"): + if key in svc and not isinstance(svc[key], str): + msg = f"service {name!r}: '{key}' must be a string" + raise UnsupportedComposeError(msg) + if "group_add" in svc and not isinstance(svc["group_add"], list): + msg = f"service {name!r}: 'group_add' must be a list" + raise UnsupportedComposeError(msg) + if "labels" in svc and not isinstance(svc["labels"], list | dict): + msg = f"service {name!r}: 'labels' must be a list or mapping" + raise UnsupportedComposeError(msg) + if "entrypoint" in svc and not isinstance(svc["entrypoint"], str | list): + msg = f"service {name!r}: 'entrypoint' must be a string or list" + raise UnsupportedComposeError(msg) + + def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: """Validate one service; returns warnings, raises UnsupportedComposeError.""" warnings: list[str] = [] @@ -65,8 +87,11 @@ def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: elif key not in SUPPORTED_SERVICE_KEYS: msg = f"service {name!r}: unsupported key '{key}'" raise UnsupportedComposeError(msg) + if isinstance(svc.get("entrypoint"), str) and svc.get("command") is not None: + warnings.append(f"service {name!r}: string entrypoint runs via shell; 'command' is ignored") _validate_service_healthcheck(name, svc) _validate_service_volumes(name, svc) + _validate_service_forms(name, svc) return warnings diff --git a/planning/audits/2026-07-09-compose-spec-coverage.md b/planning/audits/2026-07-09-compose-spec-coverage.md new file mode 100644 index 0000000..adddb40 --- /dev/null +++ b/planning/audits/2026-07-09-compose-spec-coverage.md @@ -0,0 +1,127 @@ +# Compose spec coverage audit + +A sweep of every Compose service-level key against what `compose2pod` supports +now, bucketed by fit with the tool's mission: **one Podman pod, one shared +network namespace, CI/test, no bridge, no systemd**. Findings here spawn +follow-up change files; they are not themselves changes. + +The gate is `validate()` (`compose2pod/parsing.py`); the accept/ignore/reject +truth is `architecture/supported-subset.md`. This audit is forward-looking (what +we *could* add and why), not current truth. + +## Guiding principle + +The tool's single load-bearing invariant is the **shared network namespace**: +services reach each other over `127.0.0.1`, names resolve via per-container +`--add-host`. A key is honest to support only if honoring it preserves that +invariant. This gives a clean line: + +- **Invariant-preserving + per-container** → a direct `podman run` flag. Cheap. +- **Invariant-preserving + pod-wide** → hoist to `podman pod create`, reconcile + across services. Real design. +- **Invariant-violating** → reject permanently. Refusing is the contract, not a gap. + +## Supported today (11 + healthcheck) + +`image`, `build` (accepted, contents unread), `command`, `environment`, +`env_file` (string/list form only), `volumes` (short), `healthcheck`, +`depends_on`, `networks` (aliases), `hostname`, `container_name`, `tmpfs`. +Ignored with a warning: `ports`, `restart`, `stdin_open`, `tty`, and top-level +`networks` / `volumes`. + +## Bucket A — clean per-container flags (high value, low risk) + +Each appends directly in `run_flags()` as a `podman run` flag; none touches the +network namespace, so none conflicts with the pod. + +| Key | podman flag | Note | +|-----|-------------|------| +| `entrypoint` | `--entrypoint` | The glaring omission; companion to `command` | +| `user` | `--user` | Direct | +| `working_dir` | `--workdir` | Direct | +| `labels` | `--label` | list or map form | +| `extra_hosts` | `--add-host` | Merges into the existing add-host set | +| `init` | `--init` | boolean | +| `read_only` | `--read-only` | boolean | +| `stop_signal` | `--stop-signal` | Direct | +| `stop_grace_period` | `--stop-timeout` | duration parse | +| `cap_add` / `cap_drop` | `--cap-add` / `--cap-drop` | lists | +| `privileged` | `--privileged` | boolean | +| `security_opt` | `--security-opt` | list | +| `group_add` | `--group-add` | list | +| `devices` | `--device` | list | +| `pull_policy` | `--pull` | Direct | +| `platform` | `--platform` | Direct | +| `ulimits` | `--ulimit` | soft/hard map form | +| `annotations` | `--annotation` | Direct | +| `sysctls` | `--sysctl` | net-namespace sysctls are pod-level; non-net ones are per-container | + +## Bucket B — meaningful, needs translation or pod-wide reconciliation + +- **Resource limits.** Modern `deploy.resources.limits/reservations` plus legacy + `mem_limit` / `cpus` / `pids_limit` / `shm_size` / `oom_*` map onto + `--memory` / `--cpus` / `--pids-limit` / `--shm-size`. Design work is the + legacy-vs-`deploy` precedence and which of the ~20 cpu/mem knobs to honor. +- **`secrets` (+ top-level `secrets`).** `file:` source → read-only bind at + `/run/secrets/`; `environment:` source → env var. High CI value. +- **`configs` (+ top-level `configs`).** Same shape as secrets, mounted at a + declared target path. +- **`profiles`.** Gate services on a new `--profile` CLI flag; filter the + startup graph. Good CI fit (enable a seed/test profile). +- **`extends`.** Merge a service from the same or another file. Powerful but + changes the input model to multi-file — the largest design here. +- **`dns` / `dns_search` / `dns_opt`.** Pod-wide, not per-container (see the + namespace split below). Hoist to `podman pod create` and reconcile across + services. Low demand; deferred (see `deferred.md`). +- **`pid`.** Not pod-shared by default, so `pid: host` → `--pid host` and + `pid: service:x` → `--pid container:-` are clean per-container flags. + Near-zero CI demand; deferred rather than built. +- **Lifecycle hooks `post_start` / `pre_stop`.** No direct podman-run + equivalent; would emit as extra script steps. Design-heavy, low demand. + +## Bucket C — reject, split three ways + +The reject rationale is not one thing. It splits into a permanent line and a +model-dependent line; see the decision +`decisions/2026-07-09-reject-namespace-network-keys.md`. + +**C1 — invariant-violating (permanent reject).** Honoring these breaks the +shared-network invariant the tool exists to provide. +`network_mode`, `links`, `external_links`, `expose`. + +**C2 — fights a pod-shared namespace (reject unless the pod model changes).** +A Podman pod shares `net`, `uts`, `ipc` (and `cgroup`) by default. A +per-container override of these conflicts with the pod's shared namespace and +would require reshaping `--share`. +`ipc`, `uts`, `domainname`, `cgroup`, `userns_mode`. + +**C3 — niche / platform / legacy (reject; no CI demand).** +`volumes_from`, `scale`, `deploy` swarm keys (replicas/placement/update_config/ +restart_policy), `develop`/`watch`, `attach`, `logging`, `blkio_config`, +`device_cgroup_rules`, `isolation`, `credential_spec`, `storage_opt`, +`cpu_count`/`cpu_percent` (Windows), `mac_address` (deprecated), `gpus` (niche), +`runtime`, `cgroup_parent`. + +## Correction folded in + +An earlier read lumped `dns`, `pid`, and `ipc` together under "namespaces shared +at pod level." That was imprecise on two counts, both now reflected above: + +1. **`pid` is not pod-shared by default** — it is a clean per-container flag, not + a conflict. Moved to Bucket B (feasible, deferred), not C. +2. **`dns` is pod-wide, not per-container, but not hostile.** Podman rejects + `--dns` on a container that has joined a pod's netns (the netns is + `container:`, and `--dns` is invalid there), whereas `--add-host` + edits the per-container `/etc/hosts` file and is allowed — which is why the + tool already emits add-host per container. `dns` is therefore expressible on + `podman pod create`, pod-wide. Moved to Bucket B, deferred. + + Caveat: the per-container-`--dns`-invalid-in-pod behavior came from podman + docs, not a live run. Validate against a real podman before building `dns`. + +## Suggested next steps + +1. Ship Bucket A as the near-term pipeline, led by `entrypoint` (its own change). +2. Keep C1/C2 as honest rejects; sharpen `architecture/supported-subset.md`'s + reject rationale to distinguish "invariant-violating" from "feasible-but-deferred." +3. Revisit `dns`, `secrets`, `configs`, `profiles` per their `deferred.md` triggers. diff --git a/planning/changes/2026-07-09.03-core-process-identity-service-keys.md b/planning/changes/2026-07-09.03-core-process-identity-service-keys.md new file mode 100644 index 0000000..b8eab5d --- /dev/null +++ b/planning/changes/2026-07-09.03-core-process-identity-service-keys.md @@ -0,0 +1,91 @@ +--- +summary: Support the core process/identity service keys entrypoint, user, working_dir, group_add, labels; add them to the subset gate and emit them as podman run flags. +--- + +# Design: Core process & identity service keys + +## Summary + +Add five commonly-used Compose service keys to the supported subset: +`entrypoint`, `user`, `working_dir`, `group_add`, and `labels`. Each is an +invariant-preserving per-container `podman run` flag (Bucket A of +`audits/2026-07-09-compose-spec-coverage.md`), so honoring it needs no change to +the single-pod model. This is the first Bucket A bundle and establishes a +declarative flag-table pattern the rest of Bucket A reuses. + +## Motivation + +These are among the first keys a real compose file reaches for that +`compose2pod` today rejects: `entrypoint` (the obvious companion to the +already-supported `command`), the process identity (`user`, `working_dir`, +`group_add`), and `labels`. All map to plain podman flags with no pod-level +conflict, so refusing them is a gap, not an honest boundary. + +## Design + +**Gate (`parsing.py`).** Add the five keys to `SUPPORTED_SERVICE_KEYS`. Reject +malformed forms loudly, matching the existing volume/healthcheck checks: +`user`/`working_dir` must be strings; `group_add` must be a list; `labels` must +be a list or mapping; `entrypoint` must be a string or list. + +**Emission (`emit.py`).** A small module-level table drives the simple keys, so +future Bucket A keys join by adding a row: + +```python +_SCALAR_FLAGS = {"user": "--user", "working_dir": "--workdir"} +_LIST_FLAGS = {"group_add": "--group-add"} +``` + +`run_flags()` loops these, wrapping every value in `_Expand`. `labels` reuses a +shared key/value-pair helper factored out of the existing `environment` +handling (list `- K=v` / `- K`, or mapping `K: v` / `K:`), emitted as repeated +`--label`. Note the null value (`K:`) means an *empty* label here, not the +host-passthrough that `environment`'s null means -- same emitted shape, distinct +semantics; only the flag (`--label` vs `-e`) and the comment differ. + +Because these route through `run_flags()`/`_run_tokens()`, both `to_shell()` +expansion and the `referenced_variables()` stderr note pick up their `${VAR}` +references automatically -- no separate wiring. + +**`entrypoint`** is assembled in `_run_tokens()` (it spans the flag slot and the +command slot). The container argv is `entrypoint-tokens + command-tokens`: + +- List form `["a","b"]` -> tokens `["a","b"]` (exec form). +- String form `"a b"` -> tokens `["/bin/sh","-c","a b"]`, mirroring the existing + `command` string convention. + +Emit `--entrypoint ` and prepend `token[1:]` to the command position +after the image, so `podman run --entrypoint a IMAGE b ` runs +`a b `. No JSON is needed and each token stays `_Expand`-able. + +Edge to confirm at review: Docker's shell-form (string) entrypoint ignores CMD, +whereas exec (list) form appends it. Recommendation: for a string entrypoint, +do not append the service `command`, and warn when both are set (faithful to +Docker, honest via a warning rather than a silent drop). The target's +`--command` override, being explicit user intent, still applies; pin its +interaction with entrypoint in tests. + +**Promote** the five keys into `architecture/supported-subset.md` in the same PR. + +## Non-goals + +- The rest of Bucket A (`cap_add`, `read_only`, `ulimits`, ...) -- later bundles. +- Buckets B/C (`dns`, `secrets`, `network_mode`, ...) -- see the audit/decision. +- `labels`/`group_add` deep validation beyond form (values pass through verbatim). + +## Testing + +`just test-ci` at 100% coverage. Per key: happy-path flag emission; form +rejection (`UnsupportedComposeError`); `${VAR}` interpolation reaching both the +emitted fragment and the `referenced_variables()` note. For `entrypoint`: list +form, string form, string-plus-`command` warning, and the `--command`-override +interaction. `just lint-ci` clean. + +## Risk + +- **entrypoint string/command semantics** (likely x moderate): the shell-form + edge is the one subtlety; mitigated by the explicit tests above and the + review confirmation. +- **labels null semantics** (low x low): reusing the `environment` pair helper + could leak the host-passthrough meaning; mitigated by a dedicated + empty-label test and keeping only the pair-building shared, not the meaning. diff --git a/planning/decisions/2026-07-09-reject-namespace-network-keys.md b/planning/decisions/2026-07-09-reject-namespace-network-keys.md new file mode 100644 index 0000000..1380bdd --- /dev/null +++ b/planning/decisions/2026-07-09-reject-namespace-network-keys.md @@ -0,0 +1,60 @@ +--- +status: accepted +summary: Reject network_mode/links/expose permanently (invariant-violating) and ipc/uts/domainname/cgroup/userns_mode while the pod keeps default --share; dns and pid are feasible-but-deferred, not permanent rejects. +supersedes: null +superseded_by: null +--- + +# Reject network- and namespace-mode service keys, but only some permanently + +**Decision:** The Compose keys that touch a container's network or namespace +mode are rejected, but for two distinct reasons that must not be conflated: +one permanent, one contingent on the pod model. `dns` and `pid`, previously +grouped with them, are feasible and are *deferred*, not rejected. + +## Context + +While auditing Compose spec coverage +(`audits/2026-07-09-compose-spec-coverage.md`) the reject bucket was justified +with a single reason — "namespaces shared at pod level — conflict." That reason +is imprecise. `compose2pod` runs every service in one Podman pod that shares +`net`, `uts`, `ipc` (and `cgroup`) by default; `pid` is **not** shared by +default. So "shared namespace" does not uniformly explain the rejects, and the +tool risks treating a feasible key as impossible. + +The tool's one load-bearing invariant is the **shared network namespace**: +services talk over `127.0.0.1` and resolve names via per-container `--add-host`. +That invariant is the whole reason the tool exists (bridge-less CI). It is the +correct axis for deciding acceptance. + +## Decision & rationale + +- **Permanent reject (invariant-violating):** `network_mode`, `links`, + `external_links`, `expose`. Honoring any of these pulls a container out of the + shared netns or implies bridge/link semantics — silently breaking localhost + service discovery. Refusing is the contract, not a limitation. +- **Reject while the pod keeps default `--share` (fights a shared namespace):** + `ipc`, `uts`, `domainname`, `cgroup`, `userns_mode`. A per-container override + conflicts with the pod's shared namespace; supporting it means reshaping how + the pod is created for a need no CI user has raised. +- **Deferred, not rejected (feasible):** + - `dns` / `dns_search` / `dns_opt` are **pod-wide, not per-container**. Podman + rejects `--dns` on a container that has joined a pod's netns (invalid when + the netns is `container:`), while `--add-host` edits the per-container + `/etc/hosts` and is allowed — hence the tool already emits add-host per + container. `dns` is expressible on `podman pod create` and reconciled across + services. Tracked in `deferred.md`. + - `pid` is not pod-shared, so `pid: host` / `pid: service:x` map to clean + per-container `--pid` flags with no pod conflict. Feasible; low demand. + +The per-container-`--dns`-invalid-in-pod behavior is documented, not yet +observed on a live podman; validate it before building `dns` support. + +## Revisit trigger + +- A concrete CI need appears for a per-service network/namespace mode + (`ipc: host`, a private `pid` namespace, a service-specific resolver), with a + design that preserves the shared-network invariant — e.g. hoisting `dns` to + `podman pod create`, or selectively narrowing the pod's `--share`. +- A live podman run contradicts the documented `--dns`-in-pod behavior this + decision relies on. diff --git a/planning/deferred.md b/planning/deferred.md index 7e96277..b3c9a41 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -2,6 +2,23 @@ Real-but-unscheduled items, each with a revisit trigger. +## Support `dns` / `dns_search` / `dns_opt` as pod-wide options + +Unlike `--add-host` (a per-container `/etc/hosts` edit the tool already emits), +`--dns` is coupled to the network namespace, and podman rejects it on a +container that has joined a pod's netns. So `dns` cannot be a per-container +`podman run` flag — it must be hoisted to `podman pod create --dns` and applied +pod-wide, which means reconciling the values across services (union, or error on +disagreement). This is the tool's first pod-level aggregated option; it is +feasible and invariant-preserving but has no demonstrated CI demand yet. See +`decisions/2026-07-09-reject-namespace-network-keys.md` and +`audits/2026-07-09-compose-spec-coverage.md`. + +**Revisit trigger:** a user needs a service to resolve names through a specific +resolver or search domain inside the pod, or a live podman run contradicts the +documented "`--dns` invalid on a pod-joined container" behavior this deferral +assumes. Needs its own change file; validate the podman behavior first. + ## Add the compose2pod brand lockup to the README header Sibling org repos (`db-retry`, `eof-fixer`, `semvertag`, …) open their README diff --git a/tests/test_emit.py b/tests/test_emit.py index f8baf8f..e48e510 100644 --- a/tests/test_emit.py +++ b/tests/test_emit.py @@ -3,6 +3,7 @@ _Expand, command_tokens, emit_script, + entrypoint_tokens, image_for, referenced_variables, run_flags, @@ -86,6 +87,39 @@ def test_healthcheck_without_timeout_omits_health_timeout_flag(self) -> None: assert flags[4:6] == ["--health-cmd", _Expand("true")] assert "--health-timeout" not in flags + def test_user_flag(self) -> None: + flags = run_flags("app", {"image": "x", "user": "1000:1000"}, "p", [], "/b") + assert flags[4:6] == ["--user", _Expand("1000:1000")] + + def test_working_dir_flag(self) -> None: + flags = run_flags("app", {"image": "x", "working_dir": "/srv/app"}, "p", [], "/b") + assert flags[4:6] == ["--workdir", _Expand("/srv/app")] + + def test_user_and_working_dir_order(self) -> None: + svc = {"image": "x", "user": "root", "working_dir": "/app"} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--user", _Expand("root"), "--workdir", _Expand("/app")] + + def test_group_add_flag(self) -> None: + flags = run_flags("app", {"image": "x", "group_add": ["docker", 1000]}, "p", [], "/b") + assert flags[4:8] == ["--group-add", _Expand("docker"), "--group-add", _Expand("1000")] + + def test_labels_map_form(self) -> None: + svc = {"image": "x", "labels": {"team": "api", "tier": "backend"}} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--label", _Expand("team=api"), "--label", _Expand("tier=backend")] + + def test_labels_list_form(self) -> None: + svc = {"image": "x", "labels": ["team=api", "standalone"]} + flags = run_flags("app", svc, "p", [], "/b") + assert flags[4:8] == ["--label", _Expand("team=api"), "--label", _Expand("standalone")] + + def test_labels_null_value_is_empty_label(self) -> None: + # A null map value is an empty label here, NOT the host-passthrough that + # `environment`'s null means -- same emitted shape, distinct meaning. + flags = run_flags("app", {"image": "x", "labels": {"empty": None}}, "p", [], "/b") + assert flags[4:6] == ["--label", _Expand("empty")] + class TestImageAndCommand: def test_build_service_uses_ci_image(self, chats_compose: dict) -> None: @@ -108,6 +142,17 @@ def test_missing_command_is_empty(self) -> None: assert command_tokens({"image": "x"}) == [] +class TestEntrypoint: + def test_list_form_passes_through(self) -> None: + assert entrypoint_tokens({"entrypoint": ["sleep", "600"]}) == [_Expand("sleep"), _Expand("600")] + + def test_string_form_becomes_shell(self) -> None: + assert entrypoint_tokens({"entrypoint": "serve now"}) == ["/bin/sh", "-c", _Expand("serve now")] + + def test_missing_entrypoint_is_empty(self) -> None: + assert entrypoint_tokens({"image": "x"}) == [] + + class TestEmitScript: def make_script(self, chats_compose: dict) -> str: options = EmitOptions( @@ -248,6 +293,70 @@ def test_target_without_command_uses_service_command(self, chats_compose: dict) target_line = next(line for line in script.splitlines() if "--name test-pod-application" in line) assert target_line.rstrip().endswith('"python" "-m" "chats.api" || rc=$?') + def _single(self, svc: dict, command: str = "") -> str: + options = EmitOptions( + target="app", + ci_image="ci", + command=command, + pod="p", + project_dir="/proj", + artifacts=[], + allow_exit_codes=[], + ) + return emit_script(compose={"services": {"app": svc}}, options=options) + + def test_list_entrypoint_prepends_to_command(self) -> None: + script = self._single({"image": "x", "entrypoint": ["prog", "--flag"], "command": ["run"]}) + assert '--entrypoint "prog"' in script + assert '"x" "--flag" "run"' in script + + def test_string_entrypoint_ignores_service_command(self) -> None: + script = self._single({"image": "x", "entrypoint": "serve now", "command": ["dropped"]}) + assert "--entrypoint /bin/sh" in script + assert '-c "serve now"' in script + assert "dropped" not in script + + def test_command_override_still_applies_with_entrypoint(self) -> None: + script = self._single({"image": "x", "entrypoint": "serve"}, command="pytest tests") + assert "--entrypoint /bin/sh" in script + assert "pytest tests" in script.replace("'", "") + target_line = next(line for line in script.splitlines() if "--entrypoint /bin/sh" in line) + # The override tokens must land AFTER `-c "serve"`, proving they are + # passed positionally to `sh -c` ($0/$1...) rather than executed -- + # a string entrypoint still runs only `serve`, never `pytest tests`. + assert target_line.index('-c "serve"') < target_line.index("pytest") + + def test_empty_list_entrypoint_is_a_noop(self) -> None: + # Mirrors the existing `command: []` convention: an empty list means + # "no override", not "an entrypoint of zero tokens". + assert entrypoint_tokens({"entrypoint": []}) == [] + script = self._single({"image": "x", "entrypoint": [], "command": ["run"]}) + assert "--entrypoint" not in script + assert '"x" "run"' in script + + def test_list_entrypoint_composes_with_command_override(self) -> None: + script = self._single({"image": "x", "entrypoint": ["prog", "--flag"]}, command="run me") + target_line = next(line for line in script.splitlines() if "--entrypoint" in line) + assert target_line.index('--entrypoint "prog"') < target_line.index('"x"') + assert target_line.index('"x"') < target_line.index('"--flag"') + assert target_line.index('"--flag"') < target_line.index("run me") + + def test_all_process_identity_keys_compose_on_one_service(self) -> None: + svc = { + "image": "x", + "user": "1000:1000", + "working_dir": "/srv/app", + "group_add": ["docker"], + "labels": {"team": "api"}, + "entrypoint": ["serve"], + } + script = self._single(svc) + assert '--user "1000:1000"' in script + assert '--workdir "/srv/app"' in script + assert '--group-add "docker"' in script + assert '--label "team=api"' in script + assert '--entrypoint "serve"' in script + class TestReferencedVariables: def _options(self, command: str = "") -> EmitOptions: @@ -282,3 +391,21 @@ def test_command_override_vars_are_excluded(self) -> None: compose = {"services": {"app": {"image": "x", "command": "run ${CMDVAR}"}}} options = self._options(command="override ${SHELLVAR}") assert referenced_variables(compose, options) == [] + + def test_collects_from_new_process_identity_fields(self) -> None: + compose = { + "services": { + "app": { + "image": "x", + "user": "${U}", + "working_dir": "${WD}", + "group_add": ["${G}"], + "labels": {"team": "${T}"}, + } + } + } + assert referenced_variables(compose, self._options()) == ["G", "T", "U", "WD"] + + def test_collects_from_entrypoint(self) -> None: + compose = {"services": {"app": {"image": "x", "entrypoint": ["${EP}", "run"]}}} + assert referenced_variables(compose, self._options()) == ["EP"] diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 59e5f69..5538113 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -131,3 +131,39 @@ def test_service_container_name_is_accepted(self) -> None: def test_service_tmpfs_is_accepted(self) -> None: compose = {"services": {"app": {"image": "x", "tmpfs": ["/tmp:mode=1777"]}}} # noqa: S108 assert validate(compose) == [] + + def test_user_and_working_dir_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "user": "root", "working_dir": "/app"}}}) == [] + + def test_user_non_string_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'user' must be a string"): + validate({"services": {"app": {"image": "x", "user": 1000}}}) + + def test_working_dir_non_string_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'working_dir' must be a string"): + validate({"services": {"app": {"image": "x", "working_dir": ["/app"]}}}) + + def test_group_add_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "group_add": ["docker"]}}}) == [] + + def test_group_add_non_list_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'group_add' must be a list"): + validate({"services": {"app": {"image": "x", "group_add": "docker"}}}) + + def test_labels_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "labels": {"team": "api"}}}}) == [] + + def test_labels_non_list_or_map_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'labels' must be a list or mapping"): + validate({"services": {"app": {"image": "x", "labels": "team=api"}}}) + + def test_entrypoint_list_accepted(self) -> None: + assert validate({"services": {"app": {"image": "x", "entrypoint": ["run"]}}}) == [] + + def test_entrypoint_non_string_or_list_raises(self) -> None: + with pytest.raises(UnsupportedComposeError, match=r"'entrypoint' must be a string or list"): + validate({"services": {"app": {"image": "x", "entrypoint": {"a": "b"}}}}) + + def test_string_entrypoint_with_command_warns(self) -> None: + warnings = validate({"services": {"app": {"image": "x", "entrypoint": "serve", "command": ["x"]}}}) + assert any("command' is ignored" in w for w in warnings)