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
15 changes: 15 additions & 0 deletions .github/workflows/_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,18 @@ jobs:
- run: uv python pin ${{ matrix.python-version }}
- run: just install
- run: just test-ci

integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/[email protected]
with:
enable-cache: true
cache-dependency-glob: "**/pyproject.toml"
- run: uv python install 3.12
- run: uv python pin 3.12
- run: just install
- run: podman --version
- run: just test-integration
84 changes: 50 additions & 34 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ warns (ignored, behavior-neutral inside a single pod) or raises
- **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.
`annotations`, `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. `extra_hosts` is a
supported key but not a registry entry — it is pod-level, see Pod-level
options below.
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.
Expand Down Expand Up @@ -67,9 +69,11 @@ warns (ignored, behavior-neutral inside a single pod) or raises
- **`annotations`:** list or mapping, emitted as repeated `--annotation`
(`KEY=value`, or bare `KEY` for a null value), sharing the `_MAP_FLAGS`
machinery with `labels`.
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), emitted as
per-service `--add-host host:ip`. Distinct from the alias/hostname entries
(which resolve to `127.0.0.1`); IPv6 values keep their colons.
- **`extra_hosts`:** list (`- host:ip`) or mapping (`host: ip`), pod-level like
`dns`/`sysctls` — see the Pod-level options section below. Distinct from
the alias/hostname set in one respect: alias/hostname resolution is
always fixed at `127.0.0.1`, while `extra_hosts` carries a user-specified
address; IPv6 values keep their colons.
- **`pull_policy`:** a validated enum mapped to podman's `--pull`
(`if_not_present` → `missing`; `always`/`never`/`missing` pass through),
emitted literally. `build` and unknown values are rejected — compose2pod
Expand Down Expand Up @@ -181,36 +185,48 @@ it, so a handful of Compose keys cannot be per-container `podman run` flags.
compose2pod hoists them onto `podman pod create` instead
(`compose2pod/pod.py`) — the tool's only pod-create flags.

- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls` — mapped to
`--dns`, `--dns-search`, `--dns-option`, `--sysctl` respectively (`_DNS_KEYS`,
- **Supported:** `dns`, `dns_search`, `dns_opt`, `sysctls`, `extra_hosts` —
mapped to `--dns`, `--dns-search`, `--dns-option`, `--sysctl`, and (merged
with the alias/hostname set) `--add-host` respectively (`_DNS_KEYS`,
`pod.py`).
- **Aggregation is closure-scoped:** `pod_create_flags(services, order)` is
called with `order` — the target's dependency closure (`startup_order`) —
exactly like other closure-scoped constructs (secrets, configs). `dns` /
`dns_search` / `dns_opt` are unioned across the closure (deduplicated,
first-seen order); `sysctls` are unioned by key, and two services in the
closure setting the same key to different values is refused
(`UnsupportedComposeError: conflicting sysctl ...`) rather than resolved by
last-writer-wins.
- **Aggregation is closure-scoped:** `pod_create_flags(services, order,
hosts)` is called with `order` — the target's dependency closure
(`startup_order`) — exactly like other closure-scoped constructs (secrets,
configs). `dns` / `dns_search` / `dns_opt` are unioned across the closure
(deduplicated, first-seen order); `sysctls` are unioned by key, and two
services in the closure setting the same key to different values is
refused (`UnsupportedComposeError: conflicting sysctl ...`) rather than
resolved by last-writer-wins. `--add-host` is seeded from the
alias/hostname set (`hosts`, computed document-wide by `graph.hostnames`,
not closure-scoped — pre-existing, orthogonal behavior), then layered with
each closure service's `extra_hosts` (closure-scoped like `dns`/`sysctls`);
a host name landing on two different addresses — across two services'
`extra_hosts`, or between an `extra_hosts` entry and an alias's fixed
`127.0.0.1` — is refused (`UnsupportedComposeError: conflicting host ...`),
the same refuse-rather-than-guess rule as the `sysctls` conflict.
- **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
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.
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,
`${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
declared them — because the pod shares one `/etc/resolv.conf` and one
sysctl set. `validate()` (`compose2pod/parsing.py`) is target-agnostic
shape validation over the whole document: whenever any service anywhere
declares `dns` / `dns_search` / `dns_opt` / `sysctls` (`uses_pod_options`),
it emits the warning "dns/sysctls apply pod-wide -- all containers in the
pod share one /etc/resolv.conf and sysctl set", regardless of whether that
declared them — because the pod shares one `/etc/resolv.conf`, one sysctl
set, and one `/etc/hosts`. `validate()` (`compose2pod/parsing.py`) is
target-agnostic shape validation over the whole document: whenever any
service anywhere declares `dns` / `dns_search` / `dns_opt` / `sysctls` /
`extra_hosts` (`uses_pod_options`), it emits the warning "dns/sysctls/
extra_hosts apply pod-wide -- all containers in the pod share one
/etc/resolv.conf, sysctl set, and /etc/hosts", regardless of whether that
service turns out to be inside the target's closure. Conversely, at emit
time a `dns` / `sysctls` declaration on a service outside the target's
closure is silently ignored by `pod_create_flags` — no flag is emitted for
it, since that service is never run.
time a `dns` / `sysctls` / `extra_hosts` declaration on a service outside
the target's closure is silently ignored by `pod_create_flags` — no flag
is emitted for it, since that service is never run.
- **Non-goals:** per-service DNS/sysctls — impossible inside a
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
sysctl key conflict — refused instead, matching the refuse-on-conflict
Expand Down Expand Up @@ -444,13 +460,13 @@ enumeration ever appears to drift:
the healthcheck `test` command.
- **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`, `extra_hosts`,
`ulimits`, and every numeric resource-limit key (`mem_limit`, `cpus`,
`pids_limit`, ...). The rule, not the list, is authoritative: this is every
`SERVICE_KEYS` entry whose `emit` wraps its value (the
`_scalar`/`_number_scalar`/`_list`/`_map` factories, plus the custom
`extra_hosts`/`ulimits` emitters) except `pull_policy` (a validated enum
emitted verbatim from `PULL_POLICY_MAP`) and the four boolean flags
`security_opt`, `devices`, `labels`, `annotations`, `ulimits`, and every
numeric resource-limit key (`mem_limit`, `cpus`, `pids_limit`, ...). The
rule, not the list, is authoritative: this is every `SERVICE_KEYS` entry
whose `emit` wraps its value (the `_scalar`/`_number_scalar`/`_list`/`_map`
factories, plus the custom `ulimits` emitter) except `pull_policy` (a
validated enum emitted verbatim from `PULL_POLICY_MAP`) and the four
boolean flags
`init`/`read_only`/`privileged`/`oom_kill_disable` (each emits a bare flag
with no value to interpolate).

Expand Down
12 changes: 5 additions & 7 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,9 @@ def _add_volume_flags(flags: list[Token], svc: dict[str, Any], project_dir: str)
flags += ["--tmpfs", _Expand(value=mount)]


def run_flags(name: str, svc: dict[str, Any], pod: str, hosts: list[str], project_dir: str) -> list[Token]:
def run_flags(name: str, svc: dict[str, Any], pod: 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 {})
Expand Down Expand Up @@ -168,9 +166,9 @@ def _collect_vars(tokens: list[Token], names: set[str]) -> None:
names.update(variable_names(token.value))


def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions, hosts: list[str]) -> list[Token]:
def _run_tokens(name: str, services: dict[str, Any], options: EmitOptions) -> list[Token]:
svc = services[name]
tokens = run_flags(name, svc, options.pod, hosts, options.project_dir)
tokens = run_flags(name, svc, options.pod, options.project_dir)
entrypoint = entrypoint_tokens(svc)
if entrypoint:
tokens += ["--entrypoint", entrypoint[0]]
Expand Down Expand Up @@ -230,7 +228,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
if store_teardown:
teardown += f"; {store_teardown}"
lines.append(f"trap '{teardown}' EXIT")
pod_flags = pod_create_flags(services, order)
pod_flags = pod_create_flags(services, order, hosts)
_collect_vars(pod_flags, names)
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
if pod_flags:
Expand All @@ -246,7 +244,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
attempts = max(HEALTHY_WAIT_BUDGET_SECONDS // interval, 1)
lines.append(f"wait_healthy {shlex.quote(f'{options.pod}-{dep}')} {attempts} {interval}")
waited.add(dep)
run_tokens = _run_tokens(name, services, options, hosts)
run_tokens = _run_tokens(name, services, options)
_collect_vars(run_tokens, names)
if name == options.target:
_emit_target(lines, run_tokens, options)
Expand Down
9 changes: 1 addition & 8 deletions compose2pod/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,6 @@ def _extra_host_pairs(value: list[Any] | dict[str, Any]) -> list[Any]:
return [f"{host}:{ip}" for host, ip in value.items()]


def _emit_extra_hosts(value: Any) -> list[Token]: # noqa: ANN401 - Compose values are untyped YAML/JSON
tokens: list[Token] = []
for entry in _extra_host_pairs(value):
tokens += ["--add-host", _Expand(value=str(entry))]
return tokens


def _validate_pull_policy(name: str, key: str, value: Any) -> None: # noqa: ANN401 - Compose values are untyped
if value is not None and (not isinstance(value, str) or value not in PULL_POLICY_MAP):
allowed = "/".join(PULL_POLICY_MAP)
Expand Down Expand Up @@ -194,7 +187,6 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
"devices": _list("--device"),
"labels": _map("--label"),
"annotations": _map("--annotation"),
"extra_hosts": KeySpec(validate=_validate_map, emit=_emit_extra_hosts),
"pull_policy": KeySpec(validate=_validate_pull_policy, emit=_emit_pull_policy),
"ulimits": KeySpec(validate=_validate_ulimits, emit=_emit_ulimits),
"mem_limit": _number_scalar("--memory"),
Expand Down Expand Up @@ -233,4 +225,5 @@ def _emit_ulimits(value: Any) -> list[Token]: # noqa: ANN401 - Compose values a
"dns_search",
"dns_opt",
"sysctls",
"extra_hosts",
}
3 changes: 2 additions & 1 deletion compose2pod/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def validate(compose: dict[str, Any]) -> list[str]:
stores.validate(compose)
if uses_pod_options(services):
warnings.append(
"dns/sysctls apply pod-wide -- all containers in the pod share one /etc/resolv.conf and sysctl set"
"dns/sysctls/extra_hosts apply pod-wide -- all containers in the pod share one "
"/etc/resolv.conf, sysctl set, and /etc/hosts"
)
return warnings
48 changes: 41 additions & 7 deletions compose2pod/pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from typing import Any

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


_DNS_KEYS = {"dns": "--dns", "dns_search": "--dns-search", "dns_opt": "--dns-option"}
_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls")
_POD_OPTION_KEYS = (*_DNS_KEYS, "sysctls", "extra_hosts")


def _as_str_list(name: str, key: str, value: Any) -> list[str]: # noqa: ANN401 - Compose values are untyped
Expand Down Expand Up @@ -47,6 +47,8 @@ def validate_pod_options(name: str, svc: dict[str, Any]) -> None:
_as_str_list(name, key, svc[key])
if "sysctls" in svc:
_sysctl_pairs(name, svc["sysctls"])
if "extra_hosts" in svc:
_validate_map(name, "extra_hosts", svc["extra_hosts"])


def uses_pod_options(services: dict[str, Any]) -> bool:
Expand Down Expand Up @@ -85,10 +87,42 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
return tokens


def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
"""Pod-create flag tokens aggregated across the closure `order`.
def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
"""Merge alias/hostname hosts (fixed 127.0.0.1) with extra_hosts (order-scoped) into one add-host set.

dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are
unioned by key and a same-key value conflict is refused.
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)
-- relocating the flags changes nothing else observable about either source.
"""
return _dns_flags(services, order) + _sysctl_flags(services, order)
merged: dict[str, str] = {}
from_extra_hosts: set[str] = set()
for host in hosts:
merged[host] = "127.0.0.1"
for name in order:
svc = services[name]
if "extra_hosts" not in svc:
continue
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})"
raise UnsupportedComposeError(msg)
merged[host] = addr
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}"
tokens += ["--add-host", value]
return tokens


def pod_create_flags(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
"""Pod-create flag tokens aggregated across the closure `order`, plus `hosts` for add-host.

Add-host is merged from alias/hostname resolution (`hosts`, fixed 127.0.0.1) and each
closure service's `extra_hosts`, conflict-checked (see `_add_host_flags`). dns/dns_search/
dns_opt are unioned (dedup, first-seen order); sysctls are unioned by key.
"""
return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order)
5 changes: 5 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ test-ci:
test-branch:
uv run --no-sync pytest --cov=. --cov-branch --cov-fail-under=100

# Integration tests: execute generated scripts against real podman. CI-only; needs podman.
# The CLI -m overrides the default `-m 'not integration'` in pyproject addopts.
test-integration:
uv run --no-sync pytest -m integration

# Build + publish to PyPI. Version comes from the git tag ($GITHUB_REF_NAME); no pyproject bump.
# Auth via PyPI Trusted Publishing (OIDC); uv publish auto-detects the CI id-token.
publish:
Expand Down
Loading