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
13 changes: 13 additions & 0 deletions architecture/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,16 @@ module-private so the store interface (`validate`, `flags`, `create_lines`,
`teardown_line`, `referenced_variables`) hides the kinds from callers — the same
single-source shape as the service-key registry.
_Avoid_: store list, kinds table.

**Token**:
The result of rendering one Compose value into a `podman run`/`pod create`
argument — either a literal `str` (already shell-safe) or an `Expand` (a
value carrying `${VAR}` references that expand at script-run time, not at
generation time). `Token = str | Expand` in `keys.py`.
_Avoid_: arg, flag value.

**Expand**:
A token whose Compose variable references must expand when the generated
script runs, not when compose2pod generates it. In code, `Expand` in
`keys.py`.
_Avoid_: variable, placeholder.
10 changes: 5 additions & 5 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,12 @@ compose2pod hoists them onto `podman pod create` instead
- **Value shapes:** `dns` / `dns_search` / `dns_opt` accept a string or a list
of strings; `sysctls` accepts a mapping (`key: value`) or a list of
`"key=value"` strings, each value a string or number. A `${VAR}` inside a
value is wrapped in `_Expand` like other interpolated fields, so it stays
value is wrapped in `Expand` like other interpolated fields, so it stays
live at run time and counts toward `referenced_variables` — the generated
script's own shell expands it when it runs, not compose2pod at generation
time. `--add-host` entries render differently by source: an alias/hostname
entry stays a plain unquoted token (pre-existing behavior, unchanged by
this move), while an `extra_hosts` entry goes through `_Expand` (quoted,
this move), while an `extra_hosts` entry goes through `Expand` (quoted,
`${VAR}`-live) — same as before it was per-service.
- **Pod-wide divergence:** unlike every other service key, these apply to
every container in the pod once emitted — including services that never
Expand Down Expand Up @@ -461,18 +461,18 @@ 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
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
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` —
- **Service-key registry fields** whose spec wraps its value in `Expand` —
e.g. `user`, `working_dir`, `platform`, `group_add`, `cap_add`, `cap_drop`,
`security_opt`, `devices`, `labels`, `annotations`, `ulimits`, and every
numeric resource-limit key (`mem_limit`, `cpus`, `pids_limit`, ...). The
Expand Down
34 changes: 17 additions & 17 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from compose2pod.exceptions import UnsupportedComposeError
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.keys import SERVICE_KEYS, Expand, Token, key_value_pairs
from compose2pod.pod import pod_create_flags
from compose2pod.resources import deploy_resource_flags
from compose2pod.shell import to_shell, variable_names
Expand All @@ -24,7 +24,7 @@ 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(value=svc["image"])
return Expand(value=svc["image"])


def command_tokens(svc: dict[str, Any]) -> list[Token]:
Expand All @@ -33,8 +33,8 @@ def command_tokens(svc: dict[str, Any]) -> list[Token]:
if command is None:
return []
if isinstance(command, str):
return ["/bin/sh", "-c", _Expand(value=command)]
return [_Expand(value=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 @@ -43,15 +43,15 @@ def entrypoint_tokens(svc: dict[str, Any]) -> list[Token]:
if entrypoint is None:
return []
if isinstance(entrypoint, str):
return ["/bin/sh", "-c", _Expand(value=entrypoint)]
return [_Expand(value=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(value=cmd)]
flags += ["--health-cmd", Expand(value=cmd)]
if "timeout" in healthcheck:
flags += ["--health-timeout", str(healthcheck["timeout"])]
if "start_period" in healthcheck:
Expand All @@ -63,34 +63,34 @@ def _add_health_flags(flags: list[Token], healthcheck: dict[str, Any]) -> None:
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(value=str(pair))]
for pair in key_value_pairs(svc.get("environment") or {}):
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(value=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(value=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(value=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(value=mount)]
flags += ["--tmpfs", Expand(value=mount)]


def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> list[Token]:
Expand Down Expand Up @@ -168,13 +168,13 @@ class PlannedScript:


def _render(tokens: list[Token]) -> str:
return " ".join(to_shell(token.value) if isinstance(token, _Expand) else shlex.quote(token) for token in tokens)
return " ".join(to_shell(token.value) if isinstance(token, Expand) else shlex.quote(token) for token in tokens)


def _collect_vars(tokens: list[Token], names: set[str]) -> None:
"""Add the run-time variables any `_Expand` tokens expand to `names`."""
"""Add the run-time variables any `Expand` tokens expand to `names`."""
for token in tokens:
if isinstance(token, _Expand):
if isinstance(token, Expand):
names.update(variable_names(token.value))


Expand Down Expand Up @@ -220,7 +220,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:

Every token source is visited a single time and feeds both outputs: each
service's `_run_tokens` and the `pod_create_flags` render into lines *and*
have their `_Expand` variables collected, so the script and the variable
have their `Expand` variables collected, so the script and the variable
list cannot disagree about what the script expands at run time.
"""
services = compose["services"]
Expand Down
4 changes: 2 additions & 2 deletions compose2pod/extends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any

from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.keys import SERVICE_KEYS, _pairs_to_mapping
from compose2pod.keys import SERVICE_KEYS, pairs_to_mapping


# Merge policy for keys with a SERVICE_KEYS KeySpec comes from spec.merge (see
Expand Down Expand Up @@ -38,7 +38,7 @@ def _as_mapping(key: str, name: str, value: Any) -> dict[str, Any]: # noqa: ANN
return value
if isinstance(value, list):
if key == "environment":
return _pairs_to_mapping(name, key, value)
return pairs_to_mapping(name, key, value)
if key == "depends_on":
return {dep: {} for dep in value}
msg = f"service {name!r}: cannot merge {key!r} across incompatible forms"
Expand Down
34 changes: 17 additions & 17 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@


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

value: str


Token = str | _Expand
Token = str | Expand


def _key_value_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
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'
Expand Down Expand Up @@ -57,12 +57,12 @@ def _validate_bool(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C
raise UnsupportedComposeError(msg)


def _is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped YAML/JSON
def is_number(value: Any) -> bool: # noqa: ANN401 - Compose values are untyped YAML/JSON
return not isinstance(value, bool) and isinstance(value, int | float | str)


def _validate_number(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
if not _is_number(value):
if not is_number(value):
msg = f"service {name!r}: '{key}' must be a number or string"
raise UnsupportedComposeError(msg)

Expand All @@ -73,7 +73,7 @@ def _validate_list(name: str, key: str, value: Any) -> None: # noqa: ANN401 - C
raise UnsupportedComposeError(msg)


def _validate_map(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
def validate_map(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped YAML/JSON
if not isinstance(value, list | dict):
msg = f"service {name!r}: '{key}' must be a list or mapping"
raise UnsupportedComposeError(msg)
Expand All @@ -94,8 +94,8 @@ def _concat_list(name: str, key: str, base: Any, local: Any) -> list[Any]: # no
return _as_list(name, key, base) + _as_list(name, key, local)


def _pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
"""Normalize list-or-dict key-value form to a mapping; inverse of _key_value_pairs."""
def pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
"""Normalize list-or-dict key-value form to a mapping; inverse of key_value_pairs."""
if isinstance(value, dict):
return value
if isinstance(value, list):
Expand All @@ -110,12 +110,12 @@ def _pairs_to_mapping(name: str, key: str, value: Any) -> dict[str, Any]: # noq

def _merge_map(name: str, key: str, base: Any, local: Any) -> dict[str, Any]: # noqa: ANN401 - Compose values are untyped YAML/JSON
"""Merge policy for map-shaped keys: key-by-key merge, local wins."""
return {**_pairs_to_mapping(name, key, base), **_pairs_to_mapping(name, key, local)}
return {**pairs_to_mapping(name, key, base), **pairs_to_mapping(name, key, local)}


def _scalar(flag: str) -> KeySpec:
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
return [flag, _Expand(value=str(value))]
return [flag, Expand(value=str(value))]

return KeySpec(validate=_validate_scalar, emit=emit)

Expand All @@ -129,7 +129,7 @@ def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untype

def _number_scalar(flag: str) -> KeySpec:
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
return [flag, _Expand(value=str(value))]
return [flag, Expand(value=str(value))]

return KeySpec(validate=_validate_number, emit=emit)

Expand All @@ -138,7 +138,7 @@ def _list(flag: str) -> KeySpec:
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
tokens: list[Token] = []
for item in value:
tokens += [flag, _Expand(value=str(item))]
tokens += [flag, Expand(value=str(item))]
return tokens

return KeySpec(validate=_validate_list, emit=emit, merge=_concat_list)
Expand All @@ -147,14 +147,14 @@ def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untype
def _map(flag: str) -> KeySpec:
def emit(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
tokens: list[Token] = []
for pair in _key_value_pairs(value):
tokens += [flag, _Expand(value=str(pair))]
for pair in key_value_pairs(value):
tokens += [flag, Expand(value=str(pair))]
return tokens

return KeySpec(validate=_validate_map, emit=emit, merge=_merge_map)
return KeySpec(validate=validate_map, emit=emit, merge=_merge_map)


def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
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
Expand Down Expand Up @@ -204,7 +204,7 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
return []
tokens: list[Token] = []
for arg in _ulimit_args(value):
tokens += ["--ulimit", _Expand(value=arg)]
tokens += ["--ulimit", Expand(value=arg)]
return tokens


Expand Down
14 changes: 7 additions & 7 deletions compose2pod/pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any

from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.keys import Token, _Expand, _extra_host_pairs, _validate_map
from compose2pod.keys import Expand, Token, extra_host_pairs, validate_map


_DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"}
Expand Down Expand Up @@ -48,7 +48,7 @@ def validate_pod_options(name: str, svc: dict[str, Any]) -> None:
if "sysctls" in svc:
_sysctl_pairs(name, svc["sysctls"])
if "extra_hosts" in svc:
_validate_map(name, "extra_hosts", svc["extra_hosts"])
validate_map(name, "extra_hosts", svc["extra_hosts"])


def uses_pod_options(services: dict[str, Any]) -> bool:
Expand All @@ -66,7 +66,7 @@ def _dns_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
for value in _as_str_list(name, key, svc[key]):
seen[value] = None
for value in seen:
tokens += [flag, _Expand(value=value)]
tokens += [flag, Expand(value=value)]
return tokens


Expand All @@ -83,7 +83,7 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
merged[key] = val
tokens: list[Token] = []
for key, val in merged.items():
tokens += ["--sysctl", _Expand(value=f"{key}={val}")]
tokens += ["--sysctl", Expand(value=f"{key}={val}")]
return tokens


Expand All @@ -93,7 +93,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
A host name landing on two different addresses -- across services' extra_hosts,
or against an alias's fixed 127.0.0.1 -- is refused rather than guessed at, matching
the sysctls conflict rule below. Alias entries render as plain tokens (unquoted, as
before this move); extra_hosts entries render via `_Expand` (as before, quoted/interpolated)
before this move); extra_hosts entries render via `Expand` (as before, quoted/interpolated)
-- relocating the flags changes nothing else observable about either source.
"""
merged: dict[str, str] = {}
Expand All @@ -104,7 +104,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
svc = services[name]
if "extra_hosts" not in svc:
continue
for entry in _extra_host_pairs(svc["extra_hosts"]):
for entry in extra_host_pairs(svc["extra_hosts"]):
host, _sep, addr = str(entry).partition(":")
if merged.get(host, addr) != addr:
msg = f"service {name!r}: conflicting host {host!r} ({merged[host]!r} vs {addr!r})"
Expand All @@ -113,7 +113,7 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
from_extra_hosts.add(host)
tokens: list[Token] = []
for host, addr in merged.items():
value = _Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
value = Expand(value=f"{host}:{addr}") if host in from_extra_hosts else f"{host}:{addr}"
tokens += ["--add-host", value]
return tokens

Expand Down
8 changes: 4 additions & 4 deletions compose2pod/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from typing import Any

from compose2pod.exceptions import UnsupportedComposeError
from compose2pod.keys import Token, _Expand, _is_number
from compose2pod.keys import Expand, Token, is_number


# deploy.resources.limits.<field> -> (podman flag, conflicting legacy key)
_LIMITS = {"cpus": ("--cpus", "cpus"), "memory": ("--memory", "mem_limit"), "pids": ("--pids-limit", "pids_limit")}


def _check_number(name: str, field: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
if not _is_number(value):
if not is_number(value):
msg = f"service {name!r}: {field} must be a number or string"
raise UnsupportedComposeError(msg)

Expand Down Expand Up @@ -91,7 +91,7 @@ def deploy_resource_flags(svc: dict[str, Any]) -> list[Token]:
tokens: list[Token] = []
for field, (flag, _legacy) in _LIMITS.items():
if field in limits:
tokens += [flag, _Expand(value=str(limits[field]))]
tokens += [flag, Expand(value=str(limits[field]))]
if "memory" in reservations:
tokens += ["--memory-reservation", _Expand(value=str(reservations["memory"]))]
tokens += ["--memory-reservation", Expand(value=str(reservations["memory"]))]
return tokens
Loading