From 8d9b0ec5e560f4fcc8d03b8efd72a4e2fc1c94ba Mon Sep 17 00:00:00 2001 From: Demostenes Machado Date: Mon, 15 Jun 2026 16:22:24 -0300 Subject: [PATCH 1/2] Add container-compose compatibility layer for docker-compose.yml files. Introduces a Python script and supporting materials that allow users to bring up multi-container applications described in a standard docker-compose.yml using Apple's container CLI, without rewriting their existing compose files. Co-Authored-By: Claude Sonnet 4.6 --- docs/container-compose.md | 97 +++ examples/compose-example/README.md | 148 ++++ .../container-composecpython-314.pyc | Bin 0 -> 42226 bytes .../test_container_compose.cpython-314.pyc | Bin 0 -> 26249 bytes examples/compose-example/container-compose | 804 ++++++++++++++++++ examples/compose-example/docker-compose.yml | 52 ++ .../compose-example/test_container_compose.py | 351 ++++++++ 7 files changed, 1452 insertions(+) create mode 100644 docs/container-compose.md create mode 100644 examples/compose-example/README.md create mode 100644 examples/compose-example/__pycache__/container-composecpython-314.pyc create mode 100644 examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc create mode 100755 examples/compose-example/container-compose create mode 100644 examples/compose-example/docker-compose.yml create mode 100644 examples/compose-example/test_container_compose.py diff --git a/docs/container-compose.md b/docs/container-compose.md new file mode 100644 index 000000000..33b4b977d --- /dev/null +++ b/docs/container-compose.md @@ -0,0 +1,97 @@ +# container-compose + +`container-compose` is a Python script that provides a `docker-compose`-compatible interface for Apple's `container` CLI. It reads a standard `docker-compose.yml` file and translates each subcommand into the equivalent `container` invocations, so you can bring up multi-container applications without rewriting your existing compose files. + +## Why container-compose + +The `container` CLI operates on individual containers. Many projects describe their services in a `docker-compose.yml` that coordinates several containers, their networks, and their volumes. `container-compose` bridges that gap: it parses the compose file, creates the required networks and volumes, respects `depends_on` ordering, and runs each service as a labelled `container run` invocation so they can later be queried and torn down as a group. + +## Prerequisites + +- Apple `container` installed and the system service started (`container system start`) +- Python 3.11 or later +- PyYAML: `pip3 install pyyaml --break-system-packages` + +## Install + +Copy the script to a directory on your `PATH`: + +```bash +cp examples/compose-example/container-compose /usr/local/bin/container-compose +chmod +x /usr/local/bin/container-compose +``` + +## Quickstart + +```bash +# Start all services defined in docker-compose.yml (detached by default) +container-compose up + +# Check running services +container-compose ps + +# Stream logs from all services +container-compose logs -f + +# Stop and remove containers and networks +container-compose down +``` + +## Supported subcommands + +| Subcommand | Description | +|------------|-------------| +| `up [-d] [--build] [service…]` | Create networks and volumes, then start services in dependency order | +| `down [-v]` | Stop and delete containers and networks; `-v` also removes named volumes | +| `ps` | List containers for the current project | +| `logs [-f] [--tail N] [service…]` | Print (or follow) container output | +| `exec ` | Run a command in a running service container | +| `build [--no-cache] [service…]` | Build images from `build:` definitions | +| `pull [service…]` | Pull service images | +| `start / stop / restart [service…]` | Start, stop, or restart existing containers | +| `rm [-f] [-s] [service…]` | Remove stopped containers; `-s` stops them first | +| `run [--rm] [cmd…]` | Run a one-off command on a service | +| `config` | Print the parsed compose configuration | + +## Supported compose keys + +The following keys are translated to `container run` flags: + +- `image`, `build` (context, dockerfile, args) +- `command`, `entrypoint` +- `environment`, `env_file` +- `ports`, `volumes` (bind mounts and named volumes), `tmpfs` +- `networks` (first network only — see limitations below) +- `depends_on` (list and `condition` dict form) +- `labels`, `container_name` +- `mem_limit`, `cpus`, `deploy.resources.limits` +- `cap_add`, `cap_drop` +- `working_dir`, `user` +- `tty`, `stdin_open` +- `dns`, `dns_search` +- `read_only`, `init` +- `shm_size` + +## Project isolation + +Every resource (container, network, volume) is tagged with the project name, which defaults to the current directory name. Override it with `-p` or the `COMPOSE_PROJECT_NAME` environment variable: + +```bash +container-compose -p staging up +``` + +All `container-compose` commands scope their queries to the project label, so multiple projects can coexist on the same host. + +## Known limitations + +The following `docker-compose` features are not yet supported by the `container` CLI and are silently ignored or warned about: + +| Feature | Notes | +|---------|-------| +| Multiple networks per service | `container` does not support `network connect` after run; only the first declared network is attached | +| `extra_hosts: host-gateway` | Docker-specific alias; use an explicit IP instead | +| `restart` policies | `container run` has no `--restart` flag yet | +| `healthcheck` | Not surfaced on `container inspect` output | +| Swarm / deploy keys beyond `resources.limits` | Ignored | + +See [examples/compose-example](../examples/compose-example/) for a working walkthrough. diff --git a/examples/compose-example/README.md b/examples/compose-example/README.md new file mode 100644 index 000000000..0a90e46cf --- /dev/null +++ b/examples/compose-example/README.md @@ -0,0 +1,148 @@ +# Example: Run multi-container applications with container-compose + +This example shows you how to use `container-compose` to bring up a multi-service application defined in a standard `docker-compose.yml` file using Apple's `container` CLI. + +## Prerequisites + +Install and start before running the demo: + +- Apple `container`, with the system service running (`container system start`) +- Python 3.11 or later +- PyYAML: `pip3 install pyyaml --break-system-packages` + +## Install container-compose + +Copy the script to a directory on your `PATH`: + +```bash +cp container-compose /usr/local/bin/container-compose +chmod +x /usr/local/bin/container-compose +``` + +Verify the installation: + +```console +% container-compose --help +usage: container-compose [-h] [-f FILE] [-p NAME] {up,down,ps,logs,exec,build,pull,stop,start,restart,rm,run,config} ... +``` + +## The example application + +The `docker-compose.yml` in this directory describes three services: + +- **redis** — a Redis cache with a named volume and resource limits +- **api** — a Node.js application that depends on `redis`, built from a local `Dockerfile` +- **web** — an nginx front-end that depends on both `redis` and `api` + +``` +web ──depends_on──► api ──depends_on──► redis +``` + +## Start the application + +From this directory, start all services in dependency order: + +```console +% container-compose up ++ container network create compose-example_frontend ++ container network create compose-example_backend ++ container volume create compose-example_redis-data ++ container run --name compose-example-redis-1 -d ... redis:alpine ++ container run --name compose-example-api-1 -d ... myapp/api:latest ++ container run --name compose-example-web-1 -d ... nginx:latest +``` + +`up` runs detached by default. To stream output to the terminal instead, use `--no-detach`. + +## Check service status + +```console +% container-compose ps +NAME SERVICE STATUS +compose-example-redis-1 redis running +compose-example-api-1 api running +compose-example-web-1 web running +``` + +## View logs + +Print recent output from all services: + +```bash +container-compose logs +``` + +Follow log output from a specific service: + +```bash +container-compose logs -f web +``` + +Show only the last 20 lines: + +```bash +container-compose logs --tail 20 +``` + +## Run a command inside a service + +Open a shell in the running `redis` container: + +```bash +container-compose exec redis sh +``` + +Run a one-off command without affecting the running container: + +```bash +container-compose run --rm api node --version +``` + +## Rebuild and restart a service + +If you change the `api` source code: + +```bash +container-compose build api +container-compose restart api +``` + +Or rebuild everything before starting: + +```bash +container-compose up --build +``` + +## Stop and remove + +Stop all services without removing them: + +```bash +container-compose stop +``` + +Remove all containers and the project networks: + +```bash +container-compose down +``` + +Also remove the named `redis-data` volume: + +```bash +container-compose down -v +``` + +## Run with a custom project name + +By default the project name is the current directory name (`compose-example`). Override it to run multiple isolated instances side by side: + +```bash +container-compose -p staging up +container-compose -p production up +``` + +## See also + +- [`docs/container-compose.md`](../../docs/container-compose.md) — full reference for supported keys and known limitations +- [`container-compose`](./container-compose) — the script itself diff --git a/examples/compose-example/__pycache__/container-composecpython-314.pyc b/examples/compose-example/__pycache__/container-composecpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c86d37bdb5d315089774460cccf33480dac600a5 GIT binary patch literal 42226 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 F{{s{$IbQ$( literal 0 HcmV?d00001 diff --git a/examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc b/examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17a1802ac4992dcd6153034d23bc0251fb907e2c GIT binary patch literal 26249 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 [args...] + container-compose build [--no-cache] [service...] + container-compose pull [service...] + container-compose restart [service...] + container-compose stop [service...] + container-compose start [service...] + container-compose rm [-f] [--stop] [service...] + container-compose run [--rm] [cmd] [args...] + container-compose config +""" + +import argparse +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +try: + import yaml +except ImportError: + print("Error: PyYAML required — run: pip3 install pyyaml --break-system-packages", file=sys.stderr) + sys.exit(1) + +CONTAINER_BIN = os.environ.get("CONTAINER_BIN", "container") +COMPOSE_LABEL_PROJECT = "com.container-compose.project" + +import shutil as _shutil +if not _shutil.which(CONTAINER_BIN): + print( + f"Error: '{CONTAINER_BIN}' not found in PATH.\n" + "Install it from https://github.com/apple/container/releases\n" + "then run: container system start", + file=sys.stderr, + ) + sys.exit(1) + +COMPOSE_LABEL_SERVICE = "com.container-compose.service" +COMPOSE_LABEL_ONEOFF = "com.container-compose.oneoff" + + +def check_system_running(): + """Fail fast with a clear message if the container daemon is not up.""" + result = subprocess.run( + [CONTAINER_BIN, "system", "status"], + capture_output=True, text=True, + ) + if result.returncode != 0 or "XPC" in (result.stdout + result.stderr): + print( + "Error: container system service is not running.\n" + "Start it with:\n\n container system start\n", + file=sys.stderr, + ) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(cmd: list[str], *, check=True, capture=False, input=None) -> subprocess.CompletedProcess: + """Run a shell command, printing it first.""" + print(f"+ {' '.join(cmd)}", file=sys.stderr) + return subprocess.run( + cmd, + check=check, + capture_output=capture, + text=True, + input=input, + ) + + +def container(*args, check=True, capture=False) -> subprocess.CompletedProcess: + return run([CONTAINER_BIN, *args], check=check, capture=capture) + + +def container_out(*args) -> str: + result = container(*args, capture=True) + return result.stdout.strip() + + +# --------------------------------------------------------------------------- +# Compose file loading +# --------------------------------------------------------------------------- + +def find_compose_file(file_path: str | None) -> Path: + candidates = [file_path] if file_path else [ + "docker-compose.yml", "docker-compose.yaml", + "compose.yml", "compose.yaml", + ] + for c in candidates: + p = Path(c) + if p.exists(): + return p + print("Error: no compose file found in current directory.", file=sys.stderr) + sys.exit(1) + + +def load_compose(path: Path) -> dict: + with open(path) as f: + data = yaml.safe_load(f) + return data or {} + + +def project_name(override: str | None, compose_path: Path) -> str: + if override: + return override + env = os.environ.get("COMPOSE_PROJECT_NAME") + if env: + return env + # Try x-project-name in compose file + return Path(os.getcwd()).name + + +# --------------------------------------------------------------------------- +# Volume / Network helpers +# --------------------------------------------------------------------------- + +def ensure_network(project: str, net_name: str, net_config: dict) -> str: + """Create network if it doesn't exist. Returns full network name.""" + full = f"{project}_{net_name}" + result = container("network", "list", "--format", "json", check=False, capture=True) + # JSON field is "id" (equals configuration.name) + existing = {n.get("id") for n in _parse_json_lines(result.stdout)} + if full not in existing: + cmd = ["network", "create"] + for k, v in (net_config.get("labels") or {}).items(): + cmd += ["--label", f"{k}={v}"] + cmd.append(full) + container(*cmd, check=False) + return full + + +def ensure_volume(project: str, vol_name: str, vol_config: dict) -> str: + """Create named volume if it doesn't exist. Returns full volume name.""" + full = f"{project}_{vol_name}" + result = container("volume", "list", "--format", "json", check=False, capture=True) + # JSON field is "id" (equals configuration.name) + existing = {v.get("id") for v in _parse_json_lines(result.stdout)} + if full not in existing: + container("volume", "create", full, check=False) + return full + + +def _parse_json_lines(text: str) -> list[dict]: + """Parse newline-delimited JSON or a JSON array.""" + text = text.strip() + if not text: + return [] + try: + parsed = json.loads(text) + if isinstance(parsed, list): + return parsed + return [parsed] + except json.JSONDecodeError: + pass + results = [] + for line in text.splitlines(): + line = line.strip() + if line: + try: + results.append(json.loads(line)) + except json.JSONDecodeError: + pass + return results + + +# --------------------------------------------------------------------------- +# Service → container args translation +# --------------------------------------------------------------------------- + +def service_container_name(project: str, service: str, index: int = 1) -> str: + return f"{project}-{service}-{index}" + + +def build_run_args( + project: str, + service_name: str, + svc: dict, + compose: dict, + *, + detach: bool = True, + override_cmd: list[str] | None = None, + remove_on_exit: bool = False, + index: int = 1, +) -> list[str]: + """Build `container run` argument list for a service.""" + args: list[str] = ["run"] + + # Respect container_name if set, otherwise generate + name = svc.get("container_name") or service_container_name(project, service_name, index) + args += ["--name", name] + + if detach: + args.append("-d") + if remove_on_exit: + args.append("--rm") + + # Labels + args += ["-l", f"{COMPOSE_LABEL_PROJECT}={project}"] + args += ["-l", f"{COMPOSE_LABEL_SERVICE}={service_name}"] + for k, v in (svc.get("labels") or {}).items(): + args += ["-l", f"{k}={v}"] + + # Environment variables + env_vars = svc.get("environment") or {} + if isinstance(env_vars, list): + for item in env_vars: + args += ["-e", item] + else: + for k, v in env_vars.items(): + if v is None: + args += ["-e", k] + else: + args += ["-e", f"{k}={v}"] + + env_files = svc.get("env_file") or [] + if isinstance(env_files, str): + env_files = [env_files] + for ef in env_files: + args += ["--env-file", ef] + + # Ports + ports = svc.get("ports") or [] + for p in ports: + args += ["-p", str(p)] + + # Volumes + all_named_volumes = set((compose.get("volumes") or {}).keys()) + vols = svc.get("volumes") or [] + for v in vols: + if isinstance(v, dict): + src = v.get("source", "") + tgt = v.get("target", "") + ro = ",readonly" if v.get("read_only") else "" + if v.get("type") == "tmpfs": + args += ["--tmpfs", tgt] + continue + vol_str = f"{src}:{tgt}{ro}" + else: + vol_str = str(v) + + # Prefix named volumes with project name + parts = vol_str.split(":") + if parts[0] in all_named_volumes: + parts[0] = f"{project}_{parts[0]}" + vol_str = ":".join(parts) + args += ["-v", vol_str] + + # Tmpfs mounts + for t in (svc.get("tmpfs") or []): + args += ["--tmpfs", t] + + # Networks — attach to all specified networks; if none, use default + svc_networks = svc.get("networks") or list((compose.get("networks") or {}).keys()) or ["default"] + if isinstance(svc_networks, list): + svc_networks = {n: {} for n in svc_networks} + first = True + for net_name in svc_networks: + full_net = f"{project}_{net_name}" + if first: + args += ["--network", full_net] + first = False + # Additional networks need `container network connect` after run (handled in up()) + + # Hostname + hostname = svc.get("hostname") or service_name + # Note: container CLI may not have --hostname, skip if unsupported + + # Working directory + if wd := svc.get("working_dir"): + args += ["--workdir", wd] + + # User + if user := svc.get("user"): + args += ["--user", str(user)] + + # TTY / stdin + if svc.get("tty"): + args.append("-t") + if svc.get("stdin_open"): + args.append("-i") + + # Capabilities + for cap in (svc.get("cap_add") or []): + args += ["--cap-add", cap] + for cap in (svc.get("cap_drop") or []): + args += ["--cap-drop", cap] + + # DNS + for dns in _as_list(svc.get("dns")): + args += ["--dns", dns] + for ds in _as_list(svc.get("dns_search")): + args += ["--dns-search", ds] + + # extra_hosts — skip Docker-specific "host-gateway" magic; pass real IP mappings only + for entry in _as_list(svc.get("extra_hosts")): + if "host-gateway" in str(entry): + print( + f" Warning: extra_hosts 'host-gateway' is Docker-specific and skipped. " + f"Use --dns or configure host IP manually.", + file=sys.stderr, + ) + continue + # format: "hostname:ip" — pass as label or note (container CLI has no --add-host yet) + print(f" Warning: extra_hosts '{entry}' skipped — not supported by container CLI.", file=sys.stderr) + + # Resources + if mem := svc.get("mem_limit"): + args += ["--memory", str(mem)] + if cpus := svc.get("cpus"): + args += ["--cpus", str(cpus)] + + # deploy.resources.limits (Compose v3 style) + deploy = svc.get("deploy") or {} + limits = (deploy.get("resources") or {}).get("limits") or {} + if mem := limits.get("memory"): + args += ["--memory", str(mem)] + if cpus := limits.get("cpus"): + args += ["--cpus", str(cpus)] + + # Entrypoint + if ep := svc.get("entrypoint"): + if isinstance(ep, list): + ep = " ".join(ep) + args += ["--entrypoint", ep] + + # Shm size + if shm := svc.get("shm_size"): + args += ["--shm-size", str(shm)] + + # Read-only root + if svc.get("read_only"): + args.append("--read-only") + + # init + if svc.get("init"): + args.append("--init") + + # Image + image = svc.get("image") or f"{project}_{service_name}" + args.append(image) + + # Command + if override_cmd is not None: + args.extend(override_cmd) + elif cmd := svc.get("command"): + if isinstance(cmd, str): + args += cmd.split() + else: + args.extend(cmd) + + return args + + +def _as_list(val) -> list: + if val is None: + return [] + if isinstance(val, list): + return val + return [val] + + +# --------------------------------------------------------------------------- +# Dependency ordering (simple topological sort) +# --------------------------------------------------------------------------- + +def ordered_services(services: dict, selected: list[str] | None) -> list[str]: + """Return services in dependency order.""" + all_svcs = list(services.keys()) + wanted = selected if selected else all_svcs + + visited: set[str] = set() + order: list[str] = [] + + def visit(name: str): + if name in visited: + return + visited.add(name) + deps = services.get(name, {}).get("depends_on") or [] + if isinstance(deps, dict): + deps = list(deps.keys()) + for dep in deps: + if dep in services: + visit(dep) + order.append(name) + + for svc in wanted: + visit(svc) + + return order + + +# --------------------------------------------------------------------------- +# Container state queries +# --------------------------------------------------------------------------- + +def list_project_containers(project: str) -> list[dict]: + """List containers belonging to this project.""" + result = container( + "list", "--all", "--format", "json", + check=False, capture=True, + ) + containers = _parse_json_lines(result.stdout) + return [ + c for c in containers + if _label_value(c, COMPOSE_LABEL_PROJECT) == project + ] + + +def _label_value(container_info: dict, label: str) -> str | None: + # container CLI JSON: labels live at configuration.labels (dict[str,str]) + labels = ( + (container_info.get("configuration") or {}).get("labels") + or container_info.get("labels") + or {} + ) + if isinstance(labels, dict): + return labels.get(label) + return None + + +def container_name_for(project: str, service_name: str, svc: dict, index: int = 1) -> str: + """Return the actual container name, respecting container_name if set.""" + return svc.get("container_name") or service_container_name(project, service_name, index) + + +def container_is_running(name: str) -> bool: + result = container("inspect", name, check=False, capture=True) + if result.returncode != 0: + return False + info = _parse_json_lines(result.stdout) + if not info: + return False + # container CLI JSON: {"id":..., "configuration":{...}, "status":{"state":"running",...}} + status_block = info[0].get("status") or {} + state = status_block.get("state") or "" + return state.lower() == "running" + + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +def cmd_up(args, project: str, compose: dict, services: dict): + services_to_start = ordered_services(services, args.service or None) + + # Create networks + all_networks = compose.get("networks") or {"default": {}} + for net_name, net_cfg in all_networks.items(): + if (net_cfg or {}).get("external"): + continue + ensure_network(project, net_name, net_cfg or {}) + + # Create named volumes + all_volumes = compose.get("volumes") or {} + for vol_name, vol_cfg in all_volumes.items(): + if (vol_cfg or {}).get("external"): + continue + ensure_volume(project, vol_name, vol_cfg or {}) + + # Build images if requested + if args.build: + cmd_build(args, project, compose, services) + + for svc_name in services_to_start: + svc = services[svc_name] + cname = container_name_for(project, svc_name, svc) + + # Skip if already running + if container_is_running(cname): + print(f" {svc_name}: already running", file=sys.stderr) + continue + + # Build image if no `image` but has `build` and --build not set + if not svc.get("image") and svc.get("build"): + _build_service(project, svc_name, svc, no_cache=False) + svc = dict(svc) + svc["image"] = f"{project}_{svc_name}" + + run_args = build_run_args( + project, svc_name, svc, compose, + detach=args.detach, + ) + container(*run_args, check=True) + + # Note: container CLI has no `network connect` — only first network is attached at run time. + # Services that declare multiple networks will only join the first one. + svc_networks = svc.get("networks") or [] + if len(svc_networks) > 1: + print( + f" Warning: service '{svc_name}' declares multiple networks but container CLI " + f"does not support 'network connect'. Only '{svc_networks[0]}' will be attached.", + file=sys.stderr, + ) + + if not args.detach: + print("\nContainers started in foreground. Press Ctrl+C to stop.", file=sys.stderr) + + +def cmd_down(args, project: str, compose: dict, services: dict): + ctrs = list_project_containers(project) + for c in ctrs: + # container CLI JSON uses "id" as the container name + name = c.get("id") + if not name: + continue + state_val = (c.get("status") or {}).get("state") or "" + if state_val.lower() == "running": + container("stop", name, check=False) + container("delete", name, check=False) + + # Remove networks + if not (args.keep_orphans if hasattr(args, "keep_orphans") else False): + for net_name in (compose.get("networks") or {"default": {}}).keys(): + full = f"{project}_{net_name}" + container("network", "delete", full, check=False) + + # Remove volumes if requested + if args.volumes: + for vol_name in (compose.get("volumes") or {}).keys(): + full = f"{project}_{vol_name}" + container("volume", "delete", full, check=False) + + +def cmd_ps(args, project: str, compose: dict, services: dict): + ctrs = list_project_containers(project) + if not ctrs: + print(f"No containers for project '{project}'.") + return + print(f"{'NAME':<40} {'SERVICE':<20} {'STATUS':<15}") + print("-" * 75) + for c in ctrs: + name = c.get("id") or "" + svc = _label_value(c, COMPOSE_LABEL_SERVICE) or "" + status = (c.get("status") or {}).get("state") or "unknown" + print(f"{name:<40} {svc:<20} {status:<15}") + + +def cmd_logs(args, project: str, compose: dict, services: dict): + target_services = args.service if args.service else list(services.keys()) + for svc_name in target_services: + svc = services.get(svc_name, {}) + cname = container_name_for(project, svc_name, svc) + log_args = ["logs"] + if args.follow: + log_args.append("-f") + if args.tail: + log_args += ["-n", str(args.tail)] + log_args.append(cname) + container(*log_args, check=False) + + +def cmd_exec(args, project: str, compose: dict, services: dict): + svc = services.get(args.service, {}) + cname = container_name_for(project, args.service, svc) + container("exec", cname, *args.cmd) + + +def _build_service(project: str, svc_name: str, svc: dict, *, no_cache: bool): + build = svc.get("build") + if not build: + return + if isinstance(build, str): + context = build + dockerfile = None + build_args = {} + else: + context = build.get("context", ".") + dockerfile = build.get("dockerfile") + build_args = build.get("args") or {} + + image_tag = svc.get("image") or f"{project}_{svc_name}" + cmd = ["build", "-t", image_tag] + if dockerfile: + cmd += ["-f", dockerfile] + if no_cache: + cmd.append("--no-cache") + if isinstance(build_args, dict): + for k, v in build_args.items(): + cmd += ["--build-arg", f"{k}={v}"] + elif isinstance(build_args, list): + for item in build_args: + cmd += ["--build-arg", item] + cmd.append(context) + container(*cmd) + + +def cmd_build(args, project: str, compose: dict, services: dict): + target = args.service if args.service else list(services.keys()) + for svc_name in target: + svc = services[svc_name] + if svc.get("build"): + _build_service(project, svc_name, svc, no_cache=getattr(args, "no_cache", False)) + + +def cmd_pull(args, project: str, compose: dict, services: dict): + target = args.service if args.service else list(services.keys()) + for svc_name in target: + svc = services[svc_name] + if image := svc.get("image"): + container("image", "pull", image, check=False) + + +def cmd_stop(args, project: str, compose: dict, services: dict): + target = args.service if args.service else list(services.keys()) + for svc_name in target: + svc = services.get(svc_name, {}) + cname = container_name_for(project, svc_name, svc) + container("stop", cname, check=False) + + +def cmd_start(args, project: str, compose: dict, services: dict): + target = args.service if args.service else list(services.keys()) + for svc_name in target: + svc = services.get(svc_name, {}) + cname = container_name_for(project, svc_name, svc) + container("start", cname, check=False) + + +def cmd_restart(args, project: str, compose: dict, services: dict): + cmd_stop(args, project, compose, services) + cmd_start(args, project, compose, services) + + +def cmd_rm(args, project: str, compose: dict, services: dict): + target = args.service if args.service else list(services.keys()) + if args.stop: + for svc_name in target: + svc = services.get(svc_name, {}) + cname = container_name_for(project, svc_name, svc) + container("stop", cname, check=False) + for svc_name in target: + svc = services.get(svc_name, {}) + cname = container_name_for(project, svc_name, svc) + container("delete", cname, check=False) + + +def cmd_run(args, project: str, compose: dict, services: dict): + svc_name = args.service + svc = services.get(svc_name) + if not svc: + print(f"Error: unknown service '{svc_name}'", file=sys.stderr) + sys.exit(1) + + # Ensure networks and volumes exist + for net_name, net_cfg in (compose.get("networks") or {"default": {}}).items(): + if not (net_cfg or {}).get("external"): + ensure_network(project, net_name, net_cfg or {}) + for vol_name, vol_cfg in (compose.get("volumes") or {}).items(): + if not (vol_cfg or {}).get("external"): + ensure_volume(project, vol_name, vol_cfg or {}) + + run_args = build_run_args( + project, svc_name, svc, compose, + detach=False, + override_cmd=args.cmd if args.cmd else None, + remove_on_exit=args.rm, + index=int(time.time()), + ) + container(*run_args) + + +def cmd_config(args, project: str, compose: dict, services: dict): + print(yaml.dump(compose, default_flow_style=False)) + + +# --------------------------------------------------------------------------- +# CLI parser +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="container-compose", + description="docker-compose compatibility for Apple's container CLI", + ) + p.add_argument("-f", "--file", metavar="FILE", help="Compose file path") + p.add_argument("-p", "--project-name", metavar="NAME", help="Project name") + + sub = p.add_subparsers(dest="subcmd", required=True) + + # up + up = sub.add_parser("up", help="Create and start containers") + up.add_argument("-d", "--detach", action="store_true", default=True, help="Run in background (default)") + up.add_argument("--no-detach", dest="detach", action="store_false") + up.add_argument("--build", action="store_true", help="Build images before starting") + up.add_argument("--remove-orphans", action="store_true") + up.add_argument("service", nargs="*") + + # down + dn = sub.add_parser("down", help="Stop and remove containers") + dn.add_argument("-v", "--volumes", action="store_true", help="Remove named volumes") + dn.add_argument("--remove-orphans", action="store_true") + + # ps + sub.add_parser("ps", help="List containers") + + # logs + lg = sub.add_parser("logs", help="View container output") + lg.add_argument("-f", "--follow", action="store_true") + lg.add_argument("--tail", type=int, metavar="N") + lg.add_argument("service", nargs="*") + + # exec + ex = sub.add_parser("exec", help="Execute a command in a running container") + ex.add_argument("service") + ex.add_argument("cmd", nargs=argparse.REMAINDER) + + # build + bd = sub.add_parser("build", help="Build or rebuild services") + bd.add_argument("--no-cache", action="store_true") + bd.add_argument("service", nargs="*") + + # pull + pl = sub.add_parser("pull", help="Pull service images") + pl.add_argument("service", nargs="*") + + # stop + st = sub.add_parser("stop", help="Stop services") + st.add_argument("service", nargs="*") + + # start + sa = sub.add_parser("start", help="Start services") + sa.add_argument("service", nargs="*") + + # restart + rs = sub.add_parser("restart", help="Restart services") + rs.add_argument("service", nargs="*") + + # rm + rm = sub.add_parser("rm", help="Remove stopped containers") + rm.add_argument("-f", "--force", action="store_true") + rm.add_argument("-s", "--stop", action="store_true", help="Stop containers before removing") + rm.add_argument("service", nargs="*") + + # run + rn = sub.add_parser("run", help="Run a one-off command on a service") + rn.add_argument("--rm", action="store_true", help="Remove container after run") + rn.add_argument("service") + rn.add_argument("cmd", nargs=argparse.REMAINDER) + + # config + sub.add_parser("config", help="Validate and view compose config") + + return p + + +SUBCMD_MAP = { + "up": cmd_up, + "down": cmd_down, + "ps": cmd_ps, + "logs": cmd_logs, + "exec": cmd_exec, + "build": cmd_build, + "pull": cmd_pull, + "stop": cmd_stop, + "start": cmd_start, + "restart": cmd_restart, + "rm": cmd_rm, + "run": cmd_run, + "config": cmd_config, +} + + +def main(): + parser = build_parser() + args = parser.parse_args() + + # Skip system check for config (no daemon needed) + if args.subcmd != "config": + check_system_running() + + compose_path = find_compose_file(args.file) + compose = load_compose(compose_path) + project = project_name(args.project_name, compose_path) + services = compose.get("services") or {} + + fn = SUBCMD_MAP.get(args.subcmd) + if fn is None: + print(f"Unknown subcommand: {args.subcmd}", file=sys.stderr) + sys.exit(1) + + try: + fn(args, project, compose, services) + except subprocess.CalledProcessError as e: + print(f"\nError: command failed with exit code {e.returncode}:", file=sys.stderr) + print(f" {' '.join(e.cmd)}", file=sys.stderr) + sys.exit(e.returncode) + + +if __name__ == "__main__": + main() diff --git a/examples/compose-example/docker-compose.yml b/examples/compose-example/docker-compose.yml new file mode 100644 index 000000000..94eaad4b2 --- /dev/null +++ b/examples/compose-example/docker-compose.yml @@ -0,0 +1,52 @@ +version: "3.9" + +services: + web: + image: nginx:latest + ports: + - "8080:80" + environment: + - NGINX_HOST=localhost + volumes: + - ./html:/usr/share/nginx/html:ro + depends_on: + - redis + networks: + - frontend + - backend + + redis: + image: redis:alpine + volumes: + - redis-data:/data + networks: + - backend + mem_limit: 256m + cpus: 0.5 + + api: + build: + context: ./api + dockerfile: Dockerfile + image: myapp/api:latest + ports: + - "3000:3000" + environment: + NODE_ENV: production + REDIS_URL: redis://redis:6379 + depends_on: + - redis + networks: + - backend + - frontend + cap_add: + - NET_BIND_SERVICE + +volumes: + redis-data: + +networks: + frontend: + driver: bridge + backend: + driver: bridge diff --git a/examples/compose-example/test_container_compose.py b/examples/compose-example/test_container_compose.py new file mode 100644 index 000000000..a45de1447 --- /dev/null +++ b/examples/compose-example/test_container_compose.py @@ -0,0 +1,351 @@ +""" +Unit tests for container-compose. + +These tests cover pure functions only — no container daemon or network required. +The CONTAINER_BIN env var is set to a known-good binary before import so the +module-level shutil.which() check passes without the real `container` CLI. +""" + +import importlib.machinery +import importlib.util +import os +import sys +import unittest +from pathlib import Path + +os.environ.setdefault("CONTAINER_BIN", "ls") + +_script = str(Path(__file__).parent / "container-compose") +_loader = importlib.machinery.SourceFileLoader("container_compose", _script) +_spec = importlib.util.spec_from_loader("container_compose", _loader) +cc = importlib.util.module_from_spec(_spec) +_loader.exec_module(cc) + + +class TestParseJsonLines(unittest.TestCase): + def test_empty_string(self): + self.assertEqual(cc._parse_json_lines(""), []) + + def test_whitespace_only(self): + self.assertEqual(cc._parse_json_lines(" \n "), []) + + def test_json_array(self): + self.assertEqual(cc._parse_json_lines('[{"id":"a"},{"id":"b"}]'), [{"id": "a"}, {"id": "b"}]) + + def test_newline_delimited(self): + text = '{"id":"a"}\n{"id":"b"}' + self.assertEqual(cc._parse_json_lines(text), [{"id": "a"}, {"id": "b"}]) + + def test_single_object(self): + self.assertEqual(cc._parse_json_lines('{"id":"foo"}'), [{"id": "foo"}]) + + def test_invalid_lines_are_skipped(self): + text = '{"id":"a"}\nnot-json\n{"id":"b"}' + self.assertEqual(cc._parse_json_lines(text), [{"id": "a"}, {"id": "b"}]) + + +class TestAsList(unittest.TestCase): + def test_none_returns_empty(self): + self.assertEqual(cc._as_list(None), []) + + def test_list_passthrough(self): + self.assertEqual(cc._as_list(["a", "b"]), ["a", "b"]) + + def test_scalar_wrapped(self): + self.assertEqual(cc._as_list("foo"), ["foo"]) + + +class TestServiceContainerName(unittest.TestCase): + def test_default_index(self): + self.assertEqual(cc.service_container_name("proj", "web"), "proj-web-1") + + def test_custom_index(self): + self.assertEqual(cc.service_container_name("proj", "worker", 3), "proj-worker-3") + + +class TestProjectName(unittest.TestCase): + def setUp(self): + self._orig = os.environ.pop("COMPOSE_PROJECT_NAME", None) + + def tearDown(self): + if self._orig is not None: + os.environ["COMPOSE_PROJECT_NAME"] = self._orig + else: + os.environ.pop("COMPOSE_PROJECT_NAME", None) + + def test_explicit_override(self): + self.assertEqual(cc.project_name("custom", Path("docker-compose.yml")), "custom") + + def test_env_var(self): + os.environ["COMPOSE_PROJECT_NAME"] = "from-env" + self.assertEqual(cc.project_name(None, Path("docker-compose.yml")), "from-env") + + def test_cwd_fallback(self): + name = cc.project_name(None, Path("docker-compose.yml")) + self.assertEqual(name, Path(os.getcwd()).name) + + +class TestLabelValue(unittest.TestCase): + def test_nested_configuration(self): + c = {"configuration": {"labels": {"foo": "bar"}}} + self.assertEqual(cc._label_value(c, "foo"), "bar") + + def test_top_level_labels(self): + c = {"labels": {"foo": "bar"}} + self.assertEqual(cc._label_value(c, "foo"), "bar") + + def test_missing_key_returns_none(self): + c = {"configuration": {"labels": {}}} + self.assertIsNone(cc._label_value(c, "missing")) + + def test_empty_container_returns_none(self): + self.assertIsNone(cc._label_value({}, "foo")) + + +class TestOrderedServices(unittest.TestCase): + def test_independent_services_all_returned(self): + svcs = {"a": {}, "b": {}, "c": {}} + self.assertEqual(set(cc.ordered_services(svcs, None)), {"a", "b", "c"}) + + def test_dependency_precedes_dependent(self): + svcs = {"web": {"depends_on": ["db"]}, "db": {}} + order = cc.ordered_services(svcs, None) + self.assertLess(order.index("db"), order.index("web")) + + def test_chain_ordering(self): + svcs = { + "app": {"depends_on": ["api"]}, + "api": {"depends_on": ["db"]}, + "db": {}, + } + order = cc.ordered_services(svcs, None) + self.assertLess(order.index("db"), order.index("api")) + self.assertLess(order.index("api"), order.index("app")) + + def test_selected_subset(self): + svcs = {"a": {}, "b": {}, "c": {}} + order = cc.ordered_services(svcs, ["a", "c"]) + self.assertEqual(set(order), {"a", "c"}) + self.assertNotIn("b", order) + + def test_depends_on_as_dict(self): + svcs = { + "web": {"depends_on": {"db": {"condition": "service_started"}}}, + "db": {}, + } + order = cc.ordered_services(svcs, None) + self.assertLess(order.index("db"), order.index("web")) + + def test_no_duplicate_entries(self): + svcs = { + "a": {"depends_on": ["c"]}, + "b": {"depends_on": ["c"]}, + "c": {}, + } + order = cc.ordered_services(svcs, None) + self.assertEqual(len(order), len(set(order))) + + +class TestBuildRunArgs(unittest.TestCase): + def _compose(self, networks=None, volumes=None): + return { + "networks": networks if networks is not None else {"default": {}}, + "volumes": volumes if volumes is not None else {}, + } + + def _args(self, svc, **kw): + compose = kw.pop("compose", self._compose()) + return cc.build_run_args("proj", "web", svc, compose, **kw) + + # --- identity / name --- + + def test_starts_with_run(self): + args = self._args({"image": "nginx:latest"}) + self.assertEqual(args[0], "run") + + def test_generated_name(self): + args = self._args({"image": "nginx:latest"}) + idx = args.index("--name") + self.assertEqual(args[idx + 1], "proj-web-1") + + def test_custom_container_name(self): + args = self._args({"image": "nginx:latest", "container_name": "my-nginx"}) + idx = args.index("--name") + self.assertEqual(args[idx + 1], "my-nginx") + + def test_image_appears(self): + args = self._args({"image": "nginx:latest"}) + self.assertIn("nginx:latest", args) + + def test_image_defaults_to_project_service(self): + args = self._args({"build": "."}) + self.assertIn("proj_web", args) + + # --- lifecycle flags --- + + def test_detach_true(self): + self.assertIn("-d", self._args({"image": "x"}, detach=True)) + + def test_detach_false(self): + self.assertNotIn("-d", self._args({"image": "x"}, detach=False)) + + def test_remove_on_exit(self): + self.assertIn("--rm", self._args({"image": "x"}, remove_on_exit=True)) + + # --- labels --- + + def test_project_label(self): + args = self._args({"image": "x"}) + labels = [args[i + 1] for i, a in enumerate(args) if a == "-l"] + self.assertIn(f"{cc.COMPOSE_LABEL_PROJECT}=proj", labels) + + def test_service_label(self): + args = self._args({"image": "x"}) + labels = [args[i + 1] for i, a in enumerate(args) if a == "-l"] + self.assertIn(f"{cc.COMPOSE_LABEL_SERVICE}=web", labels) + + def test_user_defined_labels(self): + args = self._args({"image": "x", "labels": {"tier": "frontend"}}) + labels = [args[i + 1] for i, a in enumerate(args) if a == "-l"] + self.assertIn("tier=frontend", labels) + + # --- environment --- + + def test_env_dict_key_value(self): + args = self._args({"image": "x", "environment": {"FOO": "bar"}}) + idx = args.index("FOO=bar") + self.assertEqual(args[idx - 1], "-e") + + def test_env_dict_none_value(self): + args = self._args({"image": "x", "environment": {"KEY": None}}) + idx = args.index("KEY") + self.assertEqual(args[idx - 1], "-e") + + def test_env_list(self): + args = self._args({"image": "x", "environment": ["FOO=bar", "BAZ"]}) + self.assertIn("FOO=bar", args) + self.assertIn("BAZ", args) + + # --- ports --- + + def test_ports(self): + args = self._args({"image": "x", "ports": ["8080:80"]}) + self.assertIn("8080:80", args) + + # --- volumes --- + + def test_named_volume_prefixed(self): + compose = self._compose(volumes={"data": {}}) + args = cc.build_run_args("proj", "redis", {"image": "redis", "volumes": ["data:/data"]}, compose) + self.assertIn("proj_data:/data", args) + + def test_bind_mount_unchanged(self): + args = self._args({"image": "x", "volumes": ["./src:/app"]}) + self.assertIn("./src:/app", args) + + def test_tmpfs_shorthand(self): + args = self._args({"image": "x", "tmpfs": ["/run"]}) + self.assertIn("--tmpfs", args) + self.assertIn("/run", args) + + # --- network --- + + def test_network_uses_project_prefix(self): + compose = self._compose(networks={"frontend": {}}) + args = cc.build_run_args("proj", "web", {"image": "x", "networks": ["frontend"]}, compose) + idx = args.index("--network") + self.assertEqual(args[idx + 1], "proj_frontend") + + # --- resources --- + + def test_mem_limit(self): + args = self._args({"image": "x", "mem_limit": "256m"}) + idx = args.index("--memory") + self.assertEqual(args[idx + 1], "256m") + + def test_cpus(self): + args = self._args({"image": "x", "cpus": 0.5}) + idx = args.index("--cpus") + self.assertEqual(args[idx + 1], "0.5") + + def test_deploy_resource_limits(self): + svc = {"image": "x", "deploy": {"resources": {"limits": {"memory": "512m", "cpus": "2.0"}}}} + args = self._args(svc) + self.assertIn("512m", args) + self.assertIn("2.0", args) + + # --- entrypoint / command --- + + def test_entrypoint_string(self): + args = self._args({"image": "x", "entrypoint": "/bin/sh"}) + idx = args.index("--entrypoint") + self.assertEqual(args[idx + 1], "/bin/sh") + + def test_entrypoint_list_joined(self): + args = self._args({"image": "x", "entrypoint": ["/bin/sh", "-c"]}) + idx = args.index("--entrypoint") + self.assertEqual(args[idx + 1], "/bin/sh -c") + + def test_command_string_split(self): + args = self._args({"image": "x", "command": "echo hello"}) + self.assertIn("echo", args) + self.assertIn("hello", args) + + def test_command_list(self): + args = self._args({"image": "x", "command": ["echo", "hello"]}) + self.assertIn("echo", args) + + def test_override_cmd_replaces_command(self): + args = self._args({"image": "x", "command": "sleep 999"}, override_cmd=["echo", "hi"]) + self.assertIn("echo", args) + self.assertNotIn("sleep", args) + + # --- capabilities --- + + def test_cap_add(self): + args = self._args({"image": "x", "cap_add": ["NET_BIND_SERVICE"]}) + self.assertIn("--cap-add", args) + self.assertIn("NET_BIND_SERVICE", args) + + def test_cap_drop(self): + args = self._args({"image": "x", "cap_drop": ["ALL"]}) + self.assertIn("--cap-drop", args) + self.assertIn("ALL", args) + + # --- other flags --- + + def test_working_dir(self): + args = self._args({"image": "x", "working_dir": "/app"}) + idx = args.index("--workdir") + self.assertEqual(args[idx + 1], "/app") + + def test_user(self): + args = self._args({"image": "x", "user": "1000:1000"}) + idx = args.index("--user") + self.assertEqual(args[idx + 1], "1000:1000") + + def test_read_only(self): + self.assertIn("--read-only", self._args({"image": "x", "read_only": True})) + + def test_init(self): + self.assertIn("--init", self._args({"image": "x", "init": True})) + + def test_tty(self): + self.assertIn("-t", self._args({"image": "x", "tty": True})) + + def test_stdin_open(self): + self.assertIn("-i", self._args({"image": "x", "stdin_open": True})) + + def test_shm_size(self): + args = self._args({"image": "x", "shm_size": "64m"}) + self.assertIn("--shm-size", args) + self.assertIn("64m", args) + + def test_dns(self): + args = self._args({"image": "x", "dns": "8.8.8.8"}) + self.assertIn("--dns", args) + self.assertIn("8.8.8.8", args) + + +if __name__ == "__main__": + unittest.main() From e8358551806ee661657e98478083f41bb4b8b3b7 Mon Sep 17 00:00:00 2001 From: Demostenes Machado Date: Mon, 15 Jun 2026 16:22:24 -0300 Subject: [PATCH 2/2] Remove Python cache artifacts and add .gitignore for compose-example Co-Authored-By: Claude Sonnet 4.6 --- examples/compose-example/.gitignore | 2 ++ .../container-composecpython-314.pyc | Bin 42226 -> 0 bytes .../test_container_compose.cpython-314.pyc | Bin 26249 -> 0 bytes 3 files changed, 2 insertions(+) create mode 100644 examples/compose-example/.gitignore delete mode 100644 examples/compose-example/__pycache__/container-composecpython-314.pyc delete mode 100644 examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc diff --git a/examples/compose-example/.gitignore b/examples/compose-example/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/examples/compose-example/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/examples/compose-example/__pycache__/container-composecpython-314.pyc b/examples/compose-example/__pycache__/container-composecpython-314.pyc deleted file mode 100644 index c86d37bdb5d315089774460cccf33480dac600a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42226 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 F{{s{$IbQ$( diff --git a/examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc b/examples/compose-example/__pycache__/test_container_compose.cpython-314.pyc deleted file mode 100644 index 17a1802ac4992dcd6153034d23bc0251fb907e2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26249 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