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
58 changes: 35 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,33 @@ 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 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.
- No bridge networking or 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, with no dependencies and no compiled wheels, so it installs and runs in minimal Python images.

## Requirements

**Podman 4.9 or newer.** Every form compose2pod accepts is one Podman expresses across that
whole range, from the floor to the newest release measured (6.1): a mount option a later
Podman adds is refused until the floor reaches it, so a document that compiles here runs on
any supported Podman rather than only the newest one
Podman 4.9 or newer. compose2pod accepts only forms that every Podman from 4.9 to 6.1 can
run, so a script it generates runs on any of them
([ADR-0006](https://github.com/modern-python/compose2pod/blob/main/docs/adr/0006-docker-rejection-parity.md)).

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`, so
pod-internal name resolution works on any Podman version. `host.containers.internal` /
`host.docker.internal` are not provided — add an explicit `extra_hosts`
`host.docker.internal` are not provided; add an explicit `extra_hosts`
entry if you need them.

## Install

```bash
pip install compose2pod # core: reads compose as JSON
pip install compose2pod[yaml] # optional: read YAML directly (adds PyYAML)
pip install 'compose2pod[yaml]' # optional: read YAML directly (adds PyYAML)
```

## Usage

```bash
# YAML directly (needs the [yaml] extra)
# YAML directly (needs the 'compose2pod[yaml]' extra)
compose2pod docker-compose.yml --target app --image myimage:ci > run.sh

# Or stay dependency-free by piping JSON (e.g. via yq)
Expand All @@ -59,37 +57,51 @@ yq -o=json '.' docker-compose.yml | compose2pod --target app --image myimage:ci
sh ./run.sh
```

Options:

- `--target`: the service to run in the foreground (required).
- `--image`: the CI image that replaces every service with a `build` section (required).
- `--command`: a shell command overriding the target service's command.
- `--project-dir`: the host path that relative volume and `env_file` sources resolve against (default `.`).
- `--pod-name`: the name of the Podman pod, also used as the prefix of every container name (default `test-pod`).
- `--format`: the input format, one of `auto`, `json`, `yaml` (default `auto`, which tries JSON, then YAML).
- `--artifact SRC:DST`: a file to `podman cp` out of the target container after it exits (repeatable).
- `--allow-exit-code`: a target exit code treated as success in addition to 0 (repeatable).

## Supported compose subset

compose2pod refuses **every document `docker compose config` refuses** — a
measured property, checked continuously by a differential conformance harness
that runs the real Docker CLI and the real compose2pod pipeline over the same
YAML. So a file that compiles is a file Docker would run; where compose2pod
still refuses a form Docker accepts, it is because Podman genuinely cannot
express it (each such case is documented, not guessed).
compose2pod refuses every document `docker compose config` refuses. A test
harness runs `docker compose config` and compose2pod over the same files to
check this. So a file that compiles is a file Docker would run; where compose2pod
still refuses a form Docker accepts, it is because Podman cannot express it;
each case is documented in [`docs/adr/`](https://github.com/modern-python/compose2pod/tree/main/docs/adr/).

Within that boundary it covers most of what real compose files use:

- **Services** — `image`/`build`, `command`/`entrypoint`, `environment` and
- Services: `image`/`build`, `command`/`entrypoint`, `environment` and
`env_file` (string and long-form `{path, required, format}`), `volumes`
(short-form and long-form `--mount`, including the `bind` and `tmpfs`
option maps), `tmpfs`, `healthcheck`, `depends_on` (all conditions), `links`
(read as a dependency plus a hostname alias, as Docker reads it), network
`aliases`, `hostname`/`container_name`.
- **Confinement & metadata** — `user`, `working_dir`, `read_only`, `init`,
- Confinement and metadata: `user`, `working_dir`, `read_only`, `init`,
`privileged`, `cap_add`/`cap_drop`, `security_opt`, `devices`, `group_add`,
`platform`, `labels`, `annotations`, `pull_policy` (the quoted-boolean and
YAML-1.1 spellings Docker accepts, too).
- **Resources** — the legacy keys (`mem_limit`, `cpus`, `pids_limit`,
- Resources: the legacy keys (`mem_limit`, `cpus`, `pids_limit`,
`ulimits`, …) and the modern `deploy.resources` block.
- **Pod-wide** — `dns`/`dns_search`/`dns_opt`, `sysctls`, `extra_hosts`.
- **Composition** — same-file `extends`, `secrets`/`configs`, `profiles`.
- Pod-wide: `dns`/`dns_search`/`dns_opt`, `sysctls`, `extra_hosts`.
- Composition: same-file `extends`, `secrets`/`configs`.

Accepted and ignored with a warning, since they mean nothing inside one shared pod:
`ports`, `expose`, `restart`, `stdin_open`, `tty`, `stop_signal`,
`stop_grace_period`, and `profiles` (every service runs regardless of profile).

Compose extension fields (any `x-`-prefixed key) and YAML anchors are accepted
as-is, so a top-level `x-*` anchor block for shared config just works.
as-is, so a top-level `x-*` anchor block for shared config is accepted.
`${VAR}`-style variable interpolation is left live in the generated script,
resolved by its shell against the environment present when the script runs (no
`.env` file support). The boundary rulings — which forms are refused, and why —
`.env` file support). The boundary rulings, which forms are refused and why,
are recorded in [`docs/adr/`](https://github.com/modern-python/compose2pod/tree/main/docs/adr/).

## Status
Expand Down
13 changes: 11 additions & 2 deletions compose2pod/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,17 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--image", required=True, help="CI image replacing services that have a build section")
parser.add_argument("--project-dir", default=".", help="host path relative volume/env_file sources resolve to")
parser.add_argument("--command", default="", help="shell command overriding the target service command")
parser.add_argument("--pod-name", default="test-pod")
parser.add_argument("--format", choices=("auto", "json", "yaml"), default="auto")
parser.add_argument(
"--pod-name",
default="test-pod",
help="name of the podman pod, also the container name prefix (default: test-pod)",
)
parser.add_argument(
"--format",
choices=("auto", "json", "yaml"),
default="auto",
help="input format; auto tries JSON, then YAML (default: auto)",
)
parser.add_argument(
"--artifact",
action="append",
Expand Down
2 changes: 1 addition & 1 deletion compose2pod/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class Loader(yaml_module.SafeLoader):

def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
if _yaml is None:
msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
msg = "YAML input requires the 'yaml' extra: pip install 'compose2pod[yaml]' (or pipe JSON via yq)"
raise UnsupportedComposeError(msg)
try:
return _yaml.load(text, Loader=_build_yaml_loader(_yaml)) # noqa: S506 - SafeLoader subclass, not full load
Expand Down
9 changes: 8 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,14 @@ def test_yaml_without_pyyaml_errors(
monkeypatch.setattr(read_module, "_yaml", None)
rc = run_main("services: {}", ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch)
assert rc == EXIT_USAGE_ERROR
assert "requires the 'yaml' extra" in capsys.readouterr().err
assert "pip install 'compose2pod[yaml]'" in capsys.readouterr().err

def test_help_describes_pod_name_and_format(self, capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit):
main(["--help"])
help_text = " ".join(capsys.readouterr().out.split())
assert "--pod-name POD_NAME name of the podman pod" in help_text
assert "--format {auto,json,yaml} input format" in help_text

def test_invalid_yaml_returns_2(self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
rc = run_main("a: [1, 2", ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch)
Expand Down
Loading