Skip to content
23 changes: 23 additions & 0 deletions architecture/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Glossary

The project's ubiquitous language — the domain terms that code, specs, and
capability pages share. One term, what it *is* (not what it does), and the
synonyms to reject.

**Service-key spec**:
The `(validate, emit)` pair for one Compose service key — how that key is checked
and how it renders to `podman run` flags. In code, `KeySpec` in `keys.py`.
_Avoid_: handler, plugin, rule.

**Service-key registry**:
The table mapping each declarative service-key name to its service-key spec
(`SERVICE_KEYS` in `keys.py`); the single source both the gate (`validate`) and
the emitter (`run_flags`) derive from, so they cannot drift apart.
_Avoid_: map, dispatch table, lookup.

**Structural key**:
A supported service key handled *outside* the service-key registry because the
`emit(value)` interface cannot express it — it needs `project_dir`
(`env_file`, `volumes`), spans keys, or occupies the image/command slot
(`entrypoint`). Structural keys keep their own validate/emit machinery.
_Avoid_: special key, bespoke key (bespoke describes the spec body, not the key).
47 changes: 39 additions & 8 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ warns (ignored, behavior-neutral inside a single pod) or raises
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.
- **Service-key registry:** the declarative, uniformly-shaped flag keys —
`user`, `working_dir`, `platform`, `init`, `read_only`, `privileged`,
`group_add`, `cap_add`, `cap_drop`, `security_opt`, `devices`, `labels`,
`annotations`, `extra_hosts`, `pull_policy`, `ulimits` — are defined once,
each as a `(validate, emit)` pair, in the **service-key registry**
(`SERVICE_KEYS` in `compose2pod/keys.py`); see `architecture/glossary.md`
for the service-key spec / service-key registry / structural key terms.
The remaining keys documented below are **structural keys**, handled
outside the registry because their `emit` needs `project_dir`, spans
multiple keys, or occupies the image/command slot.
- **`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
Expand Down Expand Up @@ -133,14 +143,35 @@ colon-less entry that is not absolute (e.g. `./cache`) is malformed and raises.
## Variable interpolation

compose2pod does not resolve Compose Spec `${VAR}` references at generation
time. `to_shell()` (`compose2pod/shell.py`) instead re-encodes every
compose-derived string leaf (`environment`, `image`, `command`, `volumes`,
`tmpfs`, `env_file`, healthcheck `test`) into a double-quoted POSIX-shell
fragment with the variable references left live, so the generated script's
own shell expands them against its runtime environment when the script
runs. Only those field values pass through `to_shell()`; other document
fields (service and network names, ports, and the like) are never
interpolated. Supported forms: `$VAR`, `${VAR}`,
time. `to_shell()` (`compose2pod/shell.py`) instead re-encodes each
interpolated string leaf into a double-quoted POSIX-shell fragment with the
variable references left live, so the generated script's own shell expands
them against its runtime environment when the script runs.

The interpolated set is exactly what `_Expand(...)` wraps in
`compose2pod/emit.py` and `compose2pod/keys.py` — there is no separate list
to maintain by hand, so treat the **service-key registry**
(`SERVICE_KEYS` in `compose2pod/keys.py`; see `architecture/glossary.md`)
and the `_Expand(...)` call sites in `emit.py` as the source of truth if this
enumeration ever appears to drift:

- **Structural fields:** `image` (only when the service has no `build`
override — otherwise the CI image is used, not the compose value),
`command`, `entrypoint`, `environment`, `env_file`, `volumes`, `tmpfs`, and
the healthcheck `test` command.
- **Service-key registry fields** whose spec wraps its value in `_Expand`:
`user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`,
`security_opt`, `devices`, `labels`, `annotations`, `extra_hosts`, and
`ulimits` — every `SERVICE_KEYS` entry except `pull_policy` (a validated
enum emitted verbatim from `PULL_POLICY_MAP`) and the three boolean flags
`init`/`read_only`/`privileged` (each emits a bare flag with no value to
interpolate).

Everything else is never interpolated: `build`'s own contents (context,
dockerfile, args — never read), `depends_on`, `networks`, `hostname`, and
`container_name` (the last two are emitted as literal `--add-host
host:127.0.0.1` entries, not expanded), and the healthcheck
`timeout`/`start_period`/`retries` numbers. Supported forms: `$VAR`, `${VAR}`,
`${VAR:-default}`, `${VAR-default}`, `${VAR:?msg}`, `${VAR?msg}`,
`${VAR:+alt}`, `${VAR+alt}`, and `$$` for a literal `$`. The operator forms
map onto identical POSIX `sh` parameter expansion; bare `$VAR`/`${VAR}` is
Expand Down
124 changes: 16 additions & 108 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,18 @@

from compose2pod.graph import depends_on, hostnames, startup_order
from compose2pod.healthcheck import health_cmd, interval_seconds
from compose2pod.keys import SERVICE_KEYS, Token, _Expand, _key_value_pairs
from compose2pod.shell import to_shell, variable_names


HEALTHY_WAIT_BUDGET_SECONDS = 120

_SCALAR_FLAGS: dict[str, str] = {"user": "--user", "working_dir": "--workdir", "platform": "--platform"}

_BOOL_FLAGS: dict[str, str] = {"init": "--init", "read_only": "--read-only", "privileged": "--privileged"}

_LIST_FLAGS: dict[str, str] = {
"group_add": "--group-add",
"cap_add": "--cap-add",
"cap_drop": "--cap-drop",
"security_opt": "--security-opt",
"devices": "--device",
}

_MAP_FLAGS: dict[str, str] = {"labels": "--label", "annotations": "--annotation"}

PULL_POLICY_MAP: dict[str, str] = {
"always": "always",
"never": "never",
"missing": "missing",
"if_not_present": "missing",
}


@dataclasses.dataclass(frozen=True)
class _Expand:
"""A token whose Compose variable references expand at script-run time."""

value: str


Token = str | _Expand


def image_for(svc: dict[str, Any], ci_image: str) -> Token:
"""Services with a build section run the freshly built CI image."""
if "build" in svc:
return ci_image
return _Expand(svc["image"])
return _Expand(value=svc["image"])


def command_tokens(svc: dict[str, Any]) -> list[Token]:
Expand All @@ -57,8 +27,8 @@ def command_tokens(svc: dict[str, Any]) -> list[Token]:
if command is None:
return []
if isinstance(command, str):
return ["/bin/sh", "-c", _Expand(command)]
return [_Expand(str(token)) for token in command]
return ["/bin/sh", "-c", _Expand(value=command)]
return [_Expand(value=str(token)) for token in command]


def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
Expand All @@ -67,15 +37,15 @@ def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
if entrypoint is None:
return []
if isinstance(entrypoint, str):
return ["/bin/sh", "-c", _Expand(entrypoint)]
return [_Expand(str(token)) for token in entrypoint]
return ["/bin/sh", "-c", _Expand(value=entrypoint)]
return [_Expand(value=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"))
if cmd is not None:
flags += ["--health-cmd", _Expand(cmd)]
flags += ["--health-cmd", _Expand(value=cmd)]
if "timeout" in healthcheck:
flags += ["--health-timeout", str(healthcheck["timeout"])]
if "start_period" in healthcheck:
Expand All @@ -84,98 +54,37 @@ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
flags += ["--health-retries", str(healthcheck["retries"])]


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 _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
"""Compose extra_hosts as 'host:ip' entries; map values keep their colons (IPv6-safe)."""
if isinstance(value, list):
return value
return [f"{host}:{ip}" for host, ip 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))]
flags += ["-e", _Expand(value=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)))]
flags += ["--env-file", _Expand(value=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.
flags += ["-v", _Expand(volume)]
flags += ["-v", _Expand(value=volume)]
continue
source, destination = volume.split(":", 1)
if source.startswith("."):
# Relative bind mount: resolve against project_dir.
source = str(Path(project_dir, source))
# Absolute bind mount (starts with "/") and named volume (bare
# identifier) are both kept as-is — neither is a path to translate.
flags += ["-v", _Expand(f"{source}:{destination}")]
flags += ["-v", _Expand(value=f"{source}:{destination}")]
tmpfs = svc.get("tmpfs") or []
if isinstance(tmpfs, str):
tmpfs = [tmpfs]
for mount in tmpfs:
flags += ["--tmpfs", _Expand(mount)]


def _add_extra_host_flags(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add per-service --add-host flags from extra_hosts (explicit host:ip)."""
for entry in _extra_host_pairs(svc.get("extra_hosts") or []):
flags += ["--add-host", _Expand(str(entry))]


def _add_pull_policy_flag(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add --pull from pull_policy (a validated enum, mapped to podman's vocabulary)."""
policy = svc.get("pull_policy")
if policy is not None:
flags += ["--pull", PULL_POLICY_MAP[policy]]


def _ulimit_args(ulimits: dict[str, Any]) -> list[str]:
"""Compose ulimits as podman `name=soft:hard` / `name=value` args."""
args: list[str] = []
for limit, spec in ulimits.items():
args.append(f"{limit}={spec['soft']}:{spec['hard']}" if isinstance(spec, dict) else f"{limit}={spec}")
return args


def _add_ulimit_flags(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add --ulimit flags from ulimits (a soft/hard mapping or a scalar)."""
for arg in _ulimit_args(svc.get("ulimits") or {}):
flags += ["--ulimit", _Expand(arg)]


def _add_declarative_flags(flags: list[Token], svc: dict[str, Any]) -> None:
"""Add the scalar-, boolean-, 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 _BOOL_FLAGS.items():
if svc.get(key):
flags.append(flag)
for key, flag in _LIST_FLAGS.items():
for item in svc.get(key) or []:
flags += [flag, _Expand(str(item))]
for key, flag in _MAP_FLAGS.items():
for pair in _key_value_pairs(svc.get(key) or {}):
flags += [flag, _Expand(str(pair))]
flags += ["--tmpfs", _Expand(value=mount)]


def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]:
Expand All @@ -186,10 +95,9 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec
_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)
_add_extra_host_flags(flags, svc)
_add_pull_policy_flag(flags, svc)
_add_ulimit_flags(flags, svc)
for key, spec in SERVICE_KEYS.items():
if key in svc:
flags += spec.emit(svc[key])
return flags


Expand Down Expand Up @@ -217,7 +125,7 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], projec
"""


@dataclasses.dataclass(frozen=True)
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
class EmitOptions:
"""Options for emit_script rendering."""

Expand Down
Loading