# cli/deploy/container.py import argparse import os import subprocess import sys import time import uuid from typing import List, Tuple WORKDIR_DEFAULT = "/opt/infinito-src" def ensure_image(image: str, rebuild: bool = False, no_cache: bool = False) -> None: """ Handle Docker image creation rules: - rebuild=True => always rebuild - rebuild=False & image missing => build once - no_cache=True => add '--no-cache' to docker build """ build_args = ["docker", "build", "--network=host", "--pull"] if no_cache: build_args.append("--no-cache") build_args += ["-t", image, "."] if rebuild: print(f">>> Forcing rebuild of Docker image '{image}'...") subprocess.run(build_args, check=True) print(f">>> Docker image '{image}' rebuilt (forced).") return print(f">>> Checking if Docker image '{image}' exists...") result = subprocess.run( ["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if result.returncode == 0: print(f">>> Docker image '{image}' already exists.") return print(f">>> Docker image '{image}' not found. Building it...") subprocess.run(build_args, check=True) print(f">>> Docker image '{image}' successfully built.") def docker_exec( container: str, args: List[str], workdir: str | None = None, check: bool = True, ) -> subprocess.CompletedProcess: """ Helper to run `docker exec` with optional working directory. """ cmd = ["docker", "exec"] if workdir: cmd += ["-w", workdir] cmd.append(container) cmd += args return subprocess.run(cmd, check=check) def wait_for_inner_docker(container: str, timeout: int = 60) -> None: """ Poll `docker exec docker info` until inner dockerd is ready. """ print(">>> Waiting for inner Docker daemon inside CI container...") for _ in range(timeout): result = subprocess.run( ["docker", "exec", container, "docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) if result.returncode == 0: print(">>> Inner Docker daemon is UP.") return time.sleep(1) raise RuntimeError("Inner Docker daemon did not become ready in time") def start_ci_container( image: str, build: bool, rebuild: bool, no_cache: bool, name: str | None = None, ) -> str: """ Start a CI container running dockerd inside. Returns the container name. """ if build or rebuild: ensure_image(image, rebuild=rebuild, no_cache=no_cache) if not name: name = f"infinito-ci-{uuid.uuid4().hex[:8]}" print(f">>> Starting CI container '{name}' with inner dockerd...") subprocess.run( [ "docker", "run", "-d", "--name", name, "--network=host", "--privileged", "--cgroupns=host", image, "dockerd", "--debug", "--host=unix:///var/run/docker.sock", "--storage-driver=vfs", ], check=True, ) wait_for_inner_docker(name) print(f">>> CI container '{name}' started and inner dockerd is ready.") return name def run_in_container( image: str, build: bool, rebuild: bool, no_cache: bool, inventory_args: List[str], deploy_args: List[str], name: str | None = None, ) -> None: """ Full CI "run" mode: - start CI container with dockerd - run cli.create.inventory (with forwarded inventory_args) - ensure CI vault password file - run cli.deploy.dedicated (with forwarded deploy_args) - always remove container at the end """ container_name = None try: container_name = start_ci_container( image=image, build=build, rebuild=rebuild, no_cache=no_cache, name=name, ) # 1) Create CI inventory print(">>> Creating CI inventory inside container (cli.create.inventory)...") inventory_cmd: List[str] = [ "python3", "-m", "cli.create.inventory", "inventories/github-ci", "--host", "localhost", "--ssl-disabled", ] inventory_cmd.extend(inventory_args) docker_exec( container_name, inventory_cmd, workdir=WORKDIR_DEFAULT, check=True, ) # 2) Ensure vault password file exists print(">>> Ensuring CI vault password file exists...") docker_exec( container_name, [ "sh", "-c", "mkdir -p inventories/github-ci && " "[ -f inventories/github-ci/.password ] || " "printf '%s\n' 'ci-vault-password' > inventories/github-ci/.password", ], workdir=WORKDIR_DEFAULT, check=True, ) # 3) Run dedicated deploy print(">>> Running cli.deploy.dedicated inside CI container...") cmd = [ "python3", "-m", "cli.deploy.dedicated", "inventories/github-ci/servers.yml", "-p", "inventories/github-ci/.password", *deploy_args, ] result = docker_exec(container_name, cmd, workdir=WORKDIR_DEFAULT, check=False) if result.returncode != 0: raise subprocess.CalledProcessError(result.returncode, cmd) print(">>> Deployment finished successfully inside CI container.") finally: if container_name: print(f">>> Cleaning up CI container '{container_name}'...") subprocess.run( ["docker", "rm", "-f", container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def stop_container(name: str) -> None: print(f">>> Stopping container '{name}'...") subprocess.run(["docker", "stop", name], check=True) print(f">>> Container '{name}' stopped.") def remove_container(name: str) -> None: print(f">>> Removing container '{name}'...") subprocess.run(["docker", "rm", "-f", name], check=True) print(f">>> Container '{name}' removed.") def exec_in_container(name: str, cmd_args: List[str], workdir: str | None = WORKDIR_DEFAULT) -> int: if not cmd_args: print("Error: exec mode requires a command to run inside the container.", file=sys.stderr) return 1 print(f">>> Executing command in container '{name}': {' '.join(cmd_args)}") result = docker_exec(name, cmd_args, workdir=workdir, check=False) return result.returncode def split_inventory_and_deploy_args(rest: List[str]) -> Tuple[List[str], List[str]]: """ Split remaining arguments into: - inventory_args: passed to cli.create.inventory - deploy_args: passed to cli.deploy.dedicated Convention: - [inventory-args ...] -- [deploy-args ...] - If no '--' is present: inventory_args = [], deploy_args = all rest. """ if not rest: return [], [] if "--" in rest: idx = rest.index("--") inventory_args = rest[:idx] deploy_args = rest[idx + 1 :] else: inventory_args = [] deploy_args = rest return inventory_args, deploy_args def main() -> int: # Capture raw arguments without program name raw_argv = sys.argv[1:] # Split container-args vs forwarded args using first "--" if "--" in raw_argv: sep_index = raw_argv.index("--") container_argv = raw_argv[:sep_index] rest = raw_argv[sep_index + 1:] else: container_argv = raw_argv rest = [] parser = argparse.ArgumentParser( prog="infinito-deploy-container", description=( "Run Ansible deploy inside an infinito Docker image with an inner " "Docker daemon (dockerd + vfs) and auto-generated CI inventory.\n\n" "Usage (run mode):\n" " python -m cli.deploy.container run [container-opts] -- \\\n" " [inventory-args ...] -- [deploy-args ...]\n\n" "Example:\n" " python -m cli.deploy.container run --build -- \\\n" " --include svc-db-mariadb -- \\\n" " -T server --debug\n" ) ) parser.add_argument( "mode", choices=["run", "start", "stop", "exec", "remove"], help="Container mode: run, start, stop, exec, remove." ) parser.add_argument("--image", default=os.environ.get("INFINITO_IMAGE", "infinito:latest")) parser.add_argument("--build", action="store_true") parser.add_argument("--rebuild", action="store_true") parser.add_argument("--no-cache", action="store_true") parser.add_argument("--name") # Parse only container-level arguments args = parser.parse_args(container_argv) mode = args.mode # --- RUN MODE --- if mode == "run": inventory_args, deploy_args = split_inventory_and_deploy_args(rest) if not deploy_args: print( "Error: missing deploy arguments in run mode.\n" "Use: container run [opts] -- [inventory args] -- [deploy args]", file=sys.stderr ) return 1 try: run_in_container( image=args.image, build=args.build, rebuild=args.rebuild, no_cache=args.no_cache, inventory_args=inventory_args, deploy_args=deploy_args, name=args.name, ) except subprocess.CalledProcessError as exc: print(f"[ERROR] Deploy failed with exit code {exc.returncode}", file=sys.stderr) return exc.returncode return 0 # --- START MODE --- if mode == "start": try: name = start_ci_container( image=args.image, build=args.build, rebuild=args.rebuild, no_cache=args.no_cache, name=args.name, ) except Exception as exc: print(f"[ERROR] {exc}", file=sys.stderr) return 1 print(f">>> Started CI container: {name}") return 0 # For stop/remove/exec, a container name is mandatory if not args.name: print(f"Error: '{mode}' requires --name", file=sys.stderr) return 1 if mode == "stop": stop_container(args.name) return 0 if mode == "remove": remove_container(args.name) return 0 if mode == "exec": return exec_in_container(args.name, rest) print(f"Unknown mode: {mode}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())