"""flow dev — container management.""" import os import shutil import subprocess import sys from flow.core.config import FlowContext DEFAULT_REGISTRY = "registry.tomastm.com" DEFAULT_TAG = "latest" CONTAINER_HOME = "/home/dev" def register(subparsers): p = subparsers.add_parser("dev", help="Manage development containers") sub = p.add_subparsers(dest="dev_command") # create create = sub.add_parser("create", help="Create and start a development container") create.add_argument("name", help="Container name") create.add_argument("-i", "--image", required=True, help="Container image") create.add_argument("-p", "--project", help="Path to project directory") create.set_defaults(handler=run_create) # exec exec_cmd = sub.add_parser("exec", help="Execute command in a container") exec_cmd.add_argument("name", help="Container name") exec_cmd.add_argument("cmd", nargs="*", help="Command to run (default: interactive shell)") exec_cmd.set_defaults(handler=run_exec) # connect connect = sub.add_parser("connect", help="Attach to container tmux session") connect.add_argument("name", help="Container name") connect.set_defaults(handler=run_connect) # list ls = sub.add_parser("list", help="List development containers") ls.set_defaults(handler=run_list) # stop stop = sub.add_parser("stop", help="Stop a development container") stop.add_argument("name", help="Container name") stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop") stop.set_defaults(handler=run_stop) # remove remove = sub.add_parser("remove", aliases=["rm"], help="Remove a development container") remove.add_argument("name", help="Container name") remove.add_argument("-f", "--force", action="store_true", help="Force removal") remove.set_defaults(handler=run_remove) # respawn respawn = sub.add_parser("respawn", help="Respawn all tmux panes for a session") respawn.add_argument("name", help="Session/container name") respawn.set_defaults(handler=run_respawn) p.set_defaults(handler=lambda ctx, args: p.print_help()) def _runtime(): for rt in ("docker", "podman"): if shutil.which(rt): return rt raise RuntimeError("No container runtime found (docker or podman)") def _cname(name: str) -> str: """Normalize to dev- prefix.""" return name if name.startswith("dev-") else f"dev-{name}" def _parse_image_ref( image: str, *, default_registry: str = DEFAULT_REGISTRY, default_tag: str = DEFAULT_TAG, ): """Parse image shorthand into (full_ref, repo, tag, label).""" registry = default_registry tag = default_tag if image.startswith("docker/"): registry = "docker.io" image = f"library/{image.split('/', 1)[1]}" elif image.startswith("tm0/"): registry = default_registry image = image.split("/", 1)[1] elif "/" in image: prefix, remainder = image.split("/", 1) if "." in prefix or ":" in prefix or prefix == "localhost": registry = prefix image = remainder if ":" in image.split("/")[-1]: tag = image.rsplit(":", 1)[1] image = image.rsplit(":", 1)[0] repo = image full_ref = f"{registry}/{repo}:{tag}" label_prefix = registry.rsplit(".", 1)[0].rsplit(".", 1)[-1] if "." in registry else registry label = f"{label_prefix}/{repo.split('/')[-1]}" return full_ref, repo, tag, label def _container_exists(rt: str, cname: str) -> bool: result = subprocess.run( [rt, "container", "ls", "-a", "--format", "{{.Names}}"], capture_output=True, text=True, ) return cname in result.stdout.strip().split("\n") def _container_running(rt: str, cname: str) -> bool: result = subprocess.run( [rt, "container", "ls", "--format", "{{.Names}}"], capture_output=True, text=True, ) return cname in result.stdout.strip().split("\n") def run_create(ctx: FlowContext, args): rt = _runtime() cname = _cname(args.name) if _container_exists(rt, cname): ctx.console.error(f"Container already exists: {cname}") sys.exit(1) project_path = os.path.realpath(args.project) if args.project else None if project_path and not os.path.isdir(project_path): ctx.console.error(f"Invalid project path: {project_path}") sys.exit(1) full_ref, _, _, _ = _parse_image_ref( args.image, default_registry=ctx.config.container_registry, default_tag=ctx.config.container_tag, ) cmd = [ rt, "run", "-d", "--name", cname, "--label", "dev=true", "--label", f"dev.name={args.name}", "--label", f"dev.image_ref={full_ref}", "--network", "host", "--init", ] if project_path: cmd.extend(["-v", f"{project_path}:/workspace"]) cmd.extend(["--label", f"dev.project_path={project_path}"]) docker_sock = "/var/run/docker.sock" if os.path.exists(docker_sock): cmd.extend(["-v", f"{docker_sock}:{docker_sock}"]) home = os.path.expanduser("~") if os.path.isdir(f"{home}/.ssh"): cmd.extend(["-v", f"{home}/.ssh:{CONTAINER_HOME}/.ssh:ro"]) if os.path.isfile(f"{home}/.npmrc"): cmd.extend(["-v", f"{home}/.npmrc:{CONTAINER_HOME}/.npmrc:ro"]) if os.path.isdir(f"{home}/.npm"): cmd.extend(["-v", f"{home}/.npm:{CONTAINER_HOME}/.npm"]) # Add docker group if available try: import grp docker_gid = str(grp.getgrnam("docker").gr_gid) cmd.extend(["--group-add", docker_gid]) except (KeyError, ImportError): pass cmd.extend([full_ref, "sleep", "infinity"]) subprocess.run(cmd, check=True) ctx.console.success(f"Created and started container: {cname}") def run_exec(ctx: FlowContext, args): rt = _runtime() cname = _cname(args.name) if not _container_running(rt, cname): ctx.console.error(f"Container {cname} not running") sys.exit(1) if args.cmd: exec_cmd = [rt, "exec"] if sys.stdin.isatty(): exec_cmd.extend(["-it"]) exec_cmd.append(cname) exec_cmd.extend(args.cmd) result = subprocess.run(exec_cmd) sys.exit(result.returncode) # No command — try shells in order last_code = 0 for shell in ("zsh -l", "bash -l", "sh"): parts = shell.split() exec_cmd = [rt, "exec", "--detach-keys", "ctrl-q,ctrl-p", "-it", cname] + parts result = subprocess.run(exec_cmd) if result.returncode == 0: return last_code = result.returncode ctx.console.error(f"Unable to start an interactive shell in {cname}") sys.exit(last_code or 1) def run_connect(ctx: FlowContext, args): rt = _runtime() cname = _cname(args.name) if not _container_exists(rt, cname): ctx.console.error(f"Container does not exist: {cname}") sys.exit(1) if not _container_running(rt, cname): subprocess.run([rt, "start", cname], capture_output=True) if not shutil.which("tmux"): ctx.console.warn("tmux not found; falling back to direct exec") args.cmd = [] run_exec(ctx, args) return # Get image label for env result = subprocess.run( [rt, "container", "inspect", cname, "--format", "{{ .Config.Image }}"], capture_output=True, text=True, ) image_ref = result.stdout.strip() _, _, _, image_label = _parse_image_ref(image_ref) # Create tmux session if needed check = subprocess.run(["tmux", "has-session", "-t", cname], capture_output=True) if check.returncode != 0: ns = os.environ.get("DF_NAMESPACE", "") plat = os.environ.get("DF_PLATFORM", "") subprocess.run([ "tmux", "new-session", "-ds", cname, "-e", f"DF_IMAGE={image_label}", "-e", f"DF_NAMESPACE={ns}", "-e", f"DF_PLATFORM={plat}", f"flow dev exec {args.name}", ]) subprocess.run([ "tmux", "set-option", "-t", cname, "default-command", f"flow dev exec {args.name}", ]) if os.environ.get("TMUX"): os.execvp("tmux", ["tmux", "switch-client", "-t", cname]) else: os.execvp("tmux", ["tmux", "attach", "-t", cname]) def run_list(ctx: FlowContext, args): rt = _runtime() result = subprocess.run( [rt, "ps", "-a", "--filter", "label=dev=true", "--format", '{{.Label "dev.name"}}|{{.Image}}|{{.Label "dev.project_path"}}|{{.Status}}'], capture_output=True, text=True, ) headers = ["NAME", "IMAGE", "PROJECT", "STATUS"] rows = [] for line in result.stdout.strip().split("\n"): if not line: continue parts = line.split("|") if len(parts) >= 4: name, image, project, status = parts[0], parts[1], parts[2], parts[3] # Shorten paths home = os.path.expanduser("~") if project.startswith(home): project = "~" + project[len(home):] rows.append([name, image, project, status]) if not rows: ctx.console.info("No development containers found.") return ctx.console.table(headers, rows) def run_stop(ctx: FlowContext, args): rt = _runtime() cname = _cname(args.name) if not _container_exists(rt, cname): ctx.console.error(f"Container {cname} does not exist") sys.exit(1) if args.kill: ctx.console.info(f"Killing container {cname}...") subprocess.run([rt, "kill", cname], check=True) else: ctx.console.info(f"Stopping container {cname}...") subprocess.run([rt, "stop", cname], check=True) _tmux_fallback(cname) def run_remove(ctx: FlowContext, args): rt = _runtime() cname = _cname(args.name) if not _container_exists(rt, cname): ctx.console.error(f"Container {cname} does not exist") sys.exit(1) if args.force: ctx.console.info(f"Removing container {cname} (force)...") subprocess.run([rt, "rm", "-f", cname], check=True) else: ctx.console.info(f"Removing container {cname}...") subprocess.run([rt, "rm", cname], check=True) _tmux_fallback(cname) def run_respawn(ctx: FlowContext, args): cname = _cname(args.name) result = subprocess.run( ["tmux", "list-panes", "-t", cname, "-s", "-F", "#{session_name}:#{window_index}.#{pane_index}"], capture_output=True, text=True, ) for pane in result.stdout.strip().split("\n"): if pane: ctx.console.info(f"Respawning {pane}...") subprocess.run(["tmux", "respawn-pane", "-t", pane]) def _tmux_fallback(cname: str): """If inside tmux in the target session, switch to default.""" if not os.environ.get("TMUX"): return result = subprocess.run( ["tmux", "display-message", "-p", "#S"], capture_output=True, text=True, ) current = result.stdout.strip() if current == cname: subprocess.run(["tmux", "new-session", "-ds", "default"], capture_output=True) subprocess.run(["tmux", "switch-client", "-t", "default"])