350 lines
11 KiB
Python
350 lines
11 KiB
Python
"""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"])
|