Files
dashcaddy/ops/shipdeck-bridge/image_install.py
T
DashCaddy Polish Loop 0dd8493f98 [grade=A] Shipdeck fleet module and manual deploy flow
Adds validated fleet install/lifecycle routes, bridge-backed Git and OCI workflows, persistent service cards, and the Local-tab Shipdeck deployment UI while preserving catalog and External flows.

Judge: urn:ump:if6udffdyelsf4qvskkr65ikhuprsjiajzij6h653fhf2zgyynzq
2026-09-15 11:13:29 -07:00

104 lines
6.6 KiB
Python

"""Validated OCI image installs for shipdeck-bridge."""
import json
import os
import re
import shutil
import subprocess
import time
RE_SERVICE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
RE_IMAGE = re.compile(r"^(?:[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?/)?[A-Za-z0-9][A-Za-z0-9._/-]*(?::[A-Za-z0-9][A-Za-z0-9._-]{0,127}|@sha256:[a-f0-9]{64})$")
RE_SHA = re.compile(r"^[a-f0-9]{64}$")
RE_ENV = re.compile(r"^[A-Z_][A-Z0-9_]*$")
RE_USER = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?$")
RE_CMD0 = re.compile(r"^/?[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$")
RESTARTS = {"no", "always", "unless-stopped", "on-failure"}
DRAFT_ROOT = os.environ.get("SHIPDECK_IMAGE_DRAFTS", "/var/lib/shipdeck/drafts")
_STATE = {}
def init_shared(lock, run_fn):
_STATE.update(LOCK=lock, RUN=run_fn)
def _q(value):
return json.dumps(value, ensure_ascii=True)
def install_image(payload):
image = payload.get("image")
name = payload.get("name")
subdomain = payload.get("subdomain")
port = payload.get("port")
sha = payload.get("sha256")
env = payload.get("env") or {}
mounts = payload.get("mounts") or []
user = payload.get("user") or ""
restart = payload.get("restart") or "unless-stopped"
cmd = payload.get("cmd") or []
if not isinstance(image, str) or not RE_IMAGE.match(image): return 400, {"ok": False, "error": "invalid image"}
if not isinstance(name, str) or not RE_SERVICE.match(name): return 400, {"ok": False, "error": "invalid service name"}
if not isinstance(subdomain, str) or not RE_SERVICE.match(subdomain): return 400, {"ok": False, "error": "invalid subdomain"}
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535: return 400, {"ok": False, "error": "invalid port"}
if sha is not None and (not isinstance(sha, str) or not RE_SHA.match(sha)): return 400, {"ok": False, "error": "invalid sha256"}
if user and (not isinstance(user, str) or not RE_USER.match(user)): return 400, {"ok": False, "error": "invalid user"}
if restart not in RESTARTS: return 400, {"ok": False, "error": "invalid restart"}
if not isinstance(env, dict) or any(not isinstance(k, str) or not isinstance(v, str) or not RE_ENV.match(k) or len(v) > 300 or any(ord(c) < 32 or ord(c) == 127 for c in v) or '"' in v or '\\' in v for k, v in env.items()): return 400, {"ok": False, "error": "invalid env"}
if not isinstance(cmd, list) or len(cmd) > 64 or any(not isinstance(v, str) or not v or len(v) > 1024 or any(c in v for c in "\x00\r\n") for v in cmd): return 400, {"ok": False, "error": "invalid cmd"}
if cmd and not RE_CMD0.match(cmd[0]): return 400, {"ok": False, "error": "invalid cmd executable"}
if not isinstance(mounts, list) or len(mounts) > 32: return 400, {"ok": False, "error": "invalid mounts"}
clean_mounts = []
for mount in mounts:
if not isinstance(mount, dict): return 400, {"ok": False, "error": "invalid mount"}
source, target = mount.get("source"), mount.get("target")
if not isinstance(source, str) or not isinstance(target, str) or not source.startswith("/") or not target.startswith("/") or ".." in source or ".." in target or not re.match(r"^/[A-Za-z0-9._/-]+$", source + target): return 400, {"ok": False, "error": "invalid mount path"}
if mount.get("read_only") not in (None, True, False): return 400, {"ok": False, "error": "invalid mount mode"}
clean_mounts.append({"source": source, "target": target, "read_only": mount.get("read_only") is True})
with _STATE["LOCK"]:
pull_args = ["pull", "--json"]
if sha: pull_args += ["--sha256", sha]
pull_args.append(image)
code, pull_out = _STATE["RUN"](pull_args, 900)
if code != 0: return 500, {"ok": False, "error": "image pull failed", "output": pull_out[-4000:]}
try:
pulled = json.loads(pull_out)
digest = pulled["digest"]
if not re.match(r"^sha256:[a-f0-9]{64}$", digest): raise ValueError("bad digest")
pin = digest.split(":", 1)[1]
if not cmd:
image_cfg = (pulled.get("config") or {}).get("config") or {}
cmd = list(image_cfg.get("Entrypoint") or []) + list(image_cfg.get("Cmd") or [])
except (ValueError, KeyError, TypeError):
return 500, {"ok": False, "error": "shipdeck pull returned invalid metadata"}
if not cmd or not RE_CMD0.match(cmd[0]):
return 400, {"ok": False, "error": "image has no safe default command; provide cmd"}
ts = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True, timeout=10).stdout.split()
if not ts: return 500, {"ok": False, "error": "could not determine fleet host Tailscale IP"}
draft = os.path.join(DRAFT_ROOT, name)
if os.path.exists(draft): shutil.rmtree(draft)
os.makedirs(draft, mode=0o700, exist_ok=False)
lines = [
"# Generated by shipdeck-bridge; immutable manifest pin.", "[service]",
"name = " + _q(name), "binary = " + _q(cmd[0] if cmd else "/bin/sh"), "",
"[source]", "image = " + _q(image), "sha256 = " + _q(pin), "",
"[deploy]", 'host = "dns2"', "systemd_unit = " + _q(name + ".service"),
"port = " + str(port), "", "[runtime]", "user = " + _q(user),
"restart = " + _q(restart), "cmd = " + _q(cmd), "",
]
if env:
lines.append("[env]")
lines.extend(k + " = " + _q(v) for k, v in sorted(env.items()))
lines.append("")
for mount in clean_mounts:
lines += ["[[volumes]]", "source = " + _q(mount["source"]), "target = " + _q(mount["target"]), "read_only = " + ("true" if mount["read_only"] else "false"), ""]
lines += ["[caddy]", 'block = """', subdomain + ".sami {", "\treverse_proxy 127.0.0.1:" + str(port), "}", '"""', "tailnet_only = true", "", "[dns]", 'zone = "sami"', "record = " + _q(subdomain + ".sami"), "target = " + _q(ts[0]), "", "[verify]", "http = " + _q("https://" + subdomain + ".sami/"), "timeout = 30", ""]
path = os.path.join(draft, "Shipdeckfile")
with open(path, "w", encoding="utf-8") as fh: fh.write("\n".join(lines))
os.chmod(path, 0o600)
code, out = _STATE["RUN"](["deploy", draft], 900)
if code != 0: return 500, {"ok": False, "error": "image deploy failed", "output": out[-4000:]}
installed_path = "/var/lib/shipdeck/services/" + name + "/Shipdeckfile"
row_id = name + ":" + str(int(time.time()))
return 200, {"ok": True, "service": {"name": name, "image": image + "@sha256:" + pin, "port": port, "shipdeckfile": installed_path, "journal_row_id": row_id}, "output": out[-2000:]}