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
104 lines
6.6 KiB
Python
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:]}
|