flow
This commit is contained in:
349
commands/container.py
Normal file
349
commands/container.py
Normal 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"])
|
||||
Reference in New Issue
Block a user