This commit is contained in:
2026-02-12 09:42:59 +02:00
commit 906adb539d
87 changed files with 5288 additions and 0 deletions

349
commands/container.py Normal file
View File

@@ -0,0 +1,349 @@
"""flow dev <subcommand> — 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"])