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: 7 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,18 @@ Convert a Docker Compose file into a POSIX `sh` script that runs its services as

Built for CI and test environments where you can't use `docker compose` or `podman kube play`:

- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via `--add-host`.
- **No bridge networking / netavark.** Unprivileged CI containers often have a read-only `/proc/sys`, so netavark fails to create bridge networks. A single pod shares one network namespace with no bridge: services talk over `127.0.0.1`, and names resolve via a generated `/etc/hosts` the script owns.
- **No systemd.** Podman healthchecks are normally scheduled by systemd timers. compose2pod gates startup by polling `podman healthcheck run` directly, so `depends_on: service_healthy` works without systemd.
- **No heavy runtime.** The core is stdlib-only — no dependencies, no compiled wheels — so it installs and runs in minimal Python images.

## Requirements

**Podman >= 6.0.0.** Earlier releases have a bug where a container stopping
inside a multi-container pod wipes `/etc/hosts` for every container in that
pod, not just the one that stopped — fixed in 6.0.0. compose2pod's generated
scripts rely on one shared `--add-host`-populated `/etc/hosts` for the whole
pod (see `architecture/supported-subset.md`), so a `service_completed_successfully`
dependency (a container that runs and exits, e.g. a migration step) can wipe
name resolution for everything started after it on a pre-6.0.0 Podman.
compose2pod's generated scripts own `/etc/hosts`: they write it to a temp
file and bind-mount it read-only into every container under `--no-hosts`
(see `architecture/supported-subset.md`), so pod-internal name resolution
works on any Podman version. `host.containers.internal` /
`host.docker.internal` are not provided — add an explicit `extra_hosts`
entry if you need them.

## Install

Expand Down
54 changes: 33 additions & 21 deletions architecture/supported-subset.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,9 @@ slot (`architecture/glossary.md`).
own flag. No format validation beyond shape; a malformed option string
surfaces as a podman error at run time.
- **`hostname` and `container_name`:** each made resolvable to `127.0.0.1`
like a network alias (added to the shared `--add-host` set, see Pod-level
options), so other services can reach the service by either name. The pod
like a network alias (added to the shared, script-owned `/etc/hosts`, see
Pod-level options), so other services can reach the service by either
name. The pod
shares the UTS namespace, so a service's own hostname is the pod's; the
actual podman container is always named `{pod}-{service}` regardless of
`container_name` (used internally for `podman cp`, healthcheck polling,
Expand Down Expand Up @@ -536,7 +537,8 @@ flags. compose2pod hoists them onto `podman pod create` instead

- **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.
with the alias/hostname set) lines in the pod's owned `/etc/hosts`
respectively.
- **Value shapes:** `dns`/`dns_search` accept a string or list of strings;
`dns_opt` accepts a list of strings only — Docker refuses a bare string
there (`dns_opt: "ndots:2"`) even though it accepts one for `dns` and
Expand All @@ -556,20 +558,20 @@ flags. compose2pod hoists them onto `podman pod create` instead
emitter, and the `extends` merge — goes through the one shared
`keys.split_extra_host`, so they cannot disagree about where an entry
divides. An entry with neither separator has no address and raises, rather
than emitting a malformed `--add-host`. A `${VAR}` inside a
than emitting a malformed hosts-file line. A `${VAR}` inside a
value stays live at run time (see Variable interpolation, below) and
counts toward `referenced_variables`.
- **Aggregation is closure-scoped:** computed over the target's dependency
closure (`startup_order`), exactly like stores. `dns`/`dns_search`/
`dns_opt` are unioned (deduplicated, first-seen order); `sysctls` are
unioned by key, and two closure services setting the same key to
different values is refused (`conflicting sysctl ...`) rather than
resolved last-writer-wins. `--add-host` is seeded from the alias/hostname
set of the closure's services, then layered with each closure service's
`extra_hosts`; a host name landing on two different addresses is refused
the same way (`conflicting host ...`). An alias/hostname `--add-host`
entry stays a plain unquoted token; an `extra_hosts` entry is
`${VAR}`-live.
resolved last-writer-wins. The hosts file is seeded from the alias/hostname
set of the closure's services (each mapped to `127.0.0.1`), then layered
with each closure service's `extra_hosts` (each mapped to its address); a
host name landing on two different addresses is refused the same way
(`conflicting host ...`). An alias/hostname line stays a plain unquoted
token; an `extra_hosts` line is `${VAR}`-live.

Only the closure joins the pod, so only the closure is resolvable: a
service outside it contributes no name and cannot conflict with an
Expand All @@ -587,16 +589,26 @@ flags. compose2pod hoists them onto `podman pod create` instead
that service is in the target's closure; at emit time a declaration on a
service outside the closure is silently ignored — no flag for it, since
that service never runs.
- **Requires Podman >= 6.0.0.** Before 6.0.0, a container stopping inside a
multi-container pod wiped `/etc/hosts` for every container in the pod.
Because `--add-host` here is the pod's *only* source of `/etc/hosts`
entries, a `service_completed_successfully` dependency (a container that
runs to completion and exits, e.g. a migration step) triggers the bug and
erases name resolution for every service started after it. Confirmed
present on 5.8.1, fixed on 6.0.0/6.0.1. The generated script checks
`podman version` at startup and warns on stderr (without blocking) below
major version 6, so the requirement is visible at the point of failure,
not only in the docs. See `README.md`'s Requirements section.
- **The script owns `/etc/hosts`, not Podman.** `pod_create_flags` no longer
emits `--add-host`; instead `hosts_file_tokens` (`compose2pod/pod.py`)
renders the merged alias/hostname/`extra_hosts` set as `IP NAME` lines,
prefixed with `127.0.0.1 localhost` and `::1 localhost`. The script writes
those lines to a `mktemp` path (`hostsfile=$(mktemp)`) once, before the
first container starts, and removes it in the `trap ... EXIT` teardown
(`rm -f "$hostsfile"`). `podman pod create` and every `podman run` —
target, `--rm` completion dependency, and `-d` long-running alike — pass
`--no-hosts` and bind-mount the file read-only:
`-v "$hostsfile":/etc/hosts:ro,z`. `--no-hosts` and `--add-host` conflict,
so the two moves are one change. `z` relabels the mount for SELinux (a
no-op where SELinux is not enforcing) — without it, a bind-mounted
`/etc/hosts` is unreadable on an SELinux-enforcing host. Because
`--no-hosts` stops Podman from managing `/etc/hosts` at all, the pre-6.0.0
bug where a container stopping inside a pod wiped the shared `/etc/hosts`
for every other container cannot fire — compose2pod works identically on
every Podman version, with no version floor. `host.containers.internal` /
`host.docker.internal` are not provided: `--no-hosts` means Podman's
computed gateway IP is unavailable, so a service needing it must add an
explicit `extra_hosts` entry (see `README.md`'s Requirements section).
- **Non-goals:** per-service DNS/sysctls — impossible inside a
shared-namespace pod, not a compose2pod limitation; last-writer-wins on a
sysctl or host conflict — refused instead.
Expand Down Expand Up @@ -971,7 +983,7 @@ truth if this enumeration ever appears to drift:

Everything else is never interpolated: `build`'s own contents (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
last two are emitted as literal `127.0.0.1 host` hosts-file lines, not
expanded), and the healthcheck `timeout`/`start_period`/`retries` numbers.

Supported forms: `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR-default}`,
Expand Down
34 changes: 16 additions & 18 deletions compose2pod/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from compose2pod.healthcheck import health_cmd, interval_seconds
from compose2pod.keys import SERVICE_KEYS, Expand, GuardedEnvFile, Token
from compose2pod.parsing import validate
from compose2pod.pod import pod_create_flags
from compose2pod.pod import hosts_file_tokens, pod_create_flags
from compose2pod.resources import deploy_resource_flags
from compose2pod.shell import to_shell, variable_names
from compose2pod.values import as_bool
Expand Down Expand Up @@ -161,23 +161,16 @@ def run_flags(name: str, svc: dict[str, Any], pod: str, project_dir: str) -> lis
return flags


# Every container mounts the script-owned /etc/hosts read-only. `--no-hosts`
# stops podman managing the file (so the pre-6.0.0 stop-wipe never runs); `z`
# relabels for SELinux (mandatory on Fedora, a no-op elsewhere).
_HOSTS_MOUNT = '--no-hosts -v "$hostsfile":/etc/hosts:ro,z'

_SCRIPT_HEADER = """\
#!/bin/sh
# Generated by compose2pod -- do not edit, regenerate instead.
set -eu

podman_version=$(podman version --format '{{.Client.Version}}' 2>/dev/null) || podman_version="unknown"
podman_major=$(echo "$podman_version" | cut -d. -f1)
case "$podman_major" in
''|*[!0-9]*) ;; # unparseable -- skip rather than false-positive
*) if [ "$podman_major" -lt 6 ]; then
echo "warning: podman $podman_version detected; compose2pod requires podman >= 6.0.0" >&2
echo "warning: podman < 6.0.0 has a bug where a container stopping in a multi-container" >&2
echo "pod wipes /etc/hosts for the whole pod (fixed in 6.0.0) -- name resolution between" >&2
echo "services may fail unpredictably" >&2
fi ;;
esac

wait_healthy() {
ctr=$1
attempts=$2
Expand Down Expand Up @@ -277,7 +270,7 @@ def _emit_target(lines: list[str], tokens: list[Token], options: EmitOptions) ->
target_ctr = shlex.quote(f"{options.pod}-{options.target}")
lines.append("rc=0")
lines.extend(_env_file_preludes(tokens))
lines.append(f"podman run {_render(tokens)} || rc=$?")
lines.append(f"podman run {_HOSTS_MOUNT} {_render(tokens)} || rc=$?")
for artifact in options.artifacts:
source, destination = artifact.split(":", 1)
lines.append(f"podman cp {target_ctr}:{shlex.quote(source)} {shlex.quote(destination)} || true")
Expand Down Expand Up @@ -343,6 +336,7 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
# pointing at 127.0.0.1 for a service that never runs would turn an honest
# resolution failure into a connection-refused.
hosts = hostnames({name: services[name] for name in order})
host_tokens = hosts_file_tokens(services, order, hosts)
completion_gated = {
dep
for svc in services.values()
Expand All @@ -352,17 +346,21 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
names: set[str] = set()

lines = [_SCRIPT_HEADER]
lines.append("hostsfile=$(mktemp)")
teardown = f"podman pod rm -f {shlex.quote(options.pod)} >/dev/null 2>&1 || true"
store_teardown = stores.teardown_line(compose, order, options.pod)
if store_teardown:
teardown += f"; {store_teardown}"
teardown += '; rm -f "$hostsfile"'
lines.append(f"trap '{teardown}' EXIT")
pod_flags = pod_create_flags(services, order, hosts)
pod_flags = pod_create_flags(services, order)
_collect_vars(pod_flags, names)
pod_create = f"podman pod create --name {shlex.quote(options.pod)}"
pod_create = f"podman pod create --name {shlex.quote(options.pod)} --no-hosts"
if pod_flags:
pod_create += " " + _render(pod_flags)
lines.append(pod_create)
_collect_vars(host_tokens, names)
lines.append(f"printf '%s\\n' {_render(host_tokens)} > \"$hostsfile\"")
lines.extend(stores.create_lines(compose, order, options.pod, options.project_dir))
names |= stores.referenced_variables(compose, order, options.project_dir)
waited: set[str] = set()
Expand All @@ -379,10 +377,10 @@ def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript:
_emit_target(lines, tokens, options)
elif name in completion_gated:
lines.extend(_env_file_preludes(tokens))
lines.append(f"podman run --rm {_render(tokens)}")
lines.append(f"podman run --rm {_HOSTS_MOUNT} {_render(tokens)}")
else:
lines.extend(_env_file_preludes(tokens))
lines.append(f"podman run -d {_render(tokens)}")
lines.append(f"podman run -d {_HOSTS_MOUNT} {_render(tokens)}")
return PlannedScript(script="\n".join(lines) + "\n", variables=sorted(names))


Expand Down
44 changes: 25 additions & 19 deletions compose2pod/pod.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ def _check_extra_host_separators(name: str, value: Any) -> None: # noqa: ANN401
"""Check each list-form entry actually divides into a host and an address.

An entry with neither separator has no address to emit, and would render as
a malformed `--add-host "no-separator:"`. The mapping form cannot have this
problem: its key and value are already separate.
a malformed hosts-file line (a name with no address). The mapping form cannot
have this problem: its key and value are already separate.
"""
if not isinstance(value, list):
return
Expand All @@ -65,7 +65,7 @@ def _check_extra_host_value_types(name: str, value: Any) -> None: # noqa: ANN40
number, boolean, or null -- correct for `labels`/`annotations`' bare-key-
means-null semantics, but too loose for `extra_hosts`: an int/bool/null
address would otherwise reach `keys.extra_host_entries` and get coerced
into a bogus `--add-host` value. Measured against `docker compose config`
into a bogus hosts-file line. Measured against `docker compose config`
v5.1.2: `extra_hosts: {h: 3}` and `extra_hosts: {h: true}` are both
refused ("services.<svc>.extra_hosts.h must be a string"). The list form
cannot have this problem: `validate_map` already requires every list
Expand Down Expand Up @@ -131,14 +131,19 @@ def _sysctl_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
return tokens


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.
_LOCALHOST_LINES: tuple[str, str] = ("127.0.0.1 localhost", "::1 localhost")

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.

def hosts_file_tokens(services: dict[str, Any], order: list[str], hosts: list[str]) -> list[Token]:
"""Render the pod's /etc/hosts as `IP NAME` line tokens (localhost lines first).

compose2pod owns /etc/hosts: the generated script bind-mounts these lines
into every container under `--no-hosts`, replacing the former pod-level
`--add-host` set (which conflicts with `--no-hosts`). Merge/conflict rules
are unchanged from the add-host era: alias/hostname names are fixed at
127.0.0.1, `extra_hosts` is order-scoped, and a name landing on two
addresses is refused. Alias lines are literal tokens; `extra_hosts` lines
render via `Expand` so a `${VAR}` address stays live at run time.
"""
merged: dict[str, str] = {}
from_extra_hosts: set[str] = set()
Expand All @@ -154,18 +159,19 @@ def _add_host_flags(services: dict[str, Any], order: list[str], hosts: list[str]
raise UnsupportedComposeError(msg)
merged[host] = addr
from_extra_hosts.add(host)
tokens: list[Token] = []
tokens: list[Token] = [*_LOCALHOST_LINES]
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]
line = f"{addr} {host}"
tokens.append(Expand(value=line) if host in from_extra_hosts else line)
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.
def pod_create_flags(services: dict[str, Any], order: list[str]) -> list[Token]:
"""Pod-create flag tokens aggregated across the closure `order`.

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.
dns/dns_search/dns_opt are unioned (dedup, first-seen order); sysctls are
unioned by key. Name resolution no longer rides on `--add-host` (it conflicts
with the `--no-hosts` the script now passes); it lives in the bind-mounted
hosts file -- see `hosts_file_tokens`.
"""
return _add_host_flags(services, order, hosts) + _dns_flags(services, order) + _sysctl_flags(services, order)
return _dns_flags(services, order) + _sysctl_flags(services, order)
Loading