diff --git a/README.md b/README.md index 886c349..5e4c422 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/compose2pod/cli.py b/compose2pod/cli.py index 26c7b9e..83278aa 100644 --- a/compose2pod/cli.py +++ b/compose2pod/cli.py @@ -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", diff --git a/compose2pod/read.py b/compose2pod/read.py index bb2334d..d4a3998 100644 --- a/compose2pod/read.py +++ b/compose2pod/read.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index b2f8fb4..0cbd581 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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)