Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Ephemeral planning scratch (executable plans are not version-controlled)
.superpowers/

# Generic things
*.pyc
*~
Expand Down
30 changes: 24 additions & 6 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string>`), mirroring `command`. Emitted as
`--entrypoint <first-token>` with the remaining tokens placed ahead of the
command after the image, so `podman run --entrypoint a IMAGE b <command>`
runs `a b <command>` -- 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 <entrypoint-string>` 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 `<path>` or
`<path>:<options>` (e.g. `/tmp:mode=1777`), passed through verbatim as
`podman run --tmpfs <value>` — Compose's short syntax maps directly onto
Expand Down
80 changes: 63 additions & 17 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"))
Expand All @@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"image",
"build",
"command",
"entrypoint",
"environment",
"env_file",
"volumes",
Expand All @@ -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"}
Expand Down Expand Up @@ -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] = []
Expand All @@ -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


Expand Down
127 changes: 127 additions & 0 deletions planning/audits/2026-07-09-compose-spec-coverage.md
Original file line number Diff line number Diff line change
@@ -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/<name>`; `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:<pod>-<x>` 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:<infra>`, 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.
Loading