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/.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/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/container-compose b/examples/compose-example/container-compose new file mode 100755 index 000000000..eaa6e4fa5 --- /dev/null +++ b/examples/compose-example/container-compose @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +""" +container-compose — docker-compose compatibility layer for Apple's `container` CLI. + +Parses docker-compose.yml and translates to `container` commands. + +Usage: + container-compose up [-d] [--build] [--remove-orphans] [service...] + container-compose down [--volumes] [--remove-orphans] + container-compose ps + container-compose logs [-f] [--tail N] [service...] + container-compose exec [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()