Files
dsh/server/dsh_sync/routers/plugins.py
T

235 lines
9.4 KiB
Python

"""Immutable plugin versions: publish, list, inspect, download, delete."""
from __future__ import annotations
import io
import json
import tarfile
from pathlib import PurePosixPath
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from ..auth import Ctx
from ..db import record_audit
from ..deps import get_ctx
from ..storage import artifact_path, artifact_relpath
from ..util import PLUGIN_ID_RE, VERSION_RE, now_iso, semver_tuple, sha256_bytes
router = APIRouter()
def _inspect_tarball(data: bytes) -> tuple[dict, list[str]]:
"""Parse the tarball in memory: return (manifest, member names).
Rejects absolute paths and parent-directory traversal; accepts
manifest.json at the archive root or inside a single top-level dir.
"""
manifest = None
names: list[str] = []
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf:
for m in tf.getmembers():
parts = [p for p in PurePosixPath(m.name).parts if p != "."]
# `./` is what `tar -czf .` emits for the archive root. It is
# harmless and common, so skip it rather than rejecting the
# whole bundle.
if not parts:
continue
if parts[0] == ".." or ".." in parts or m.name.startswith("/"):
raise HTTPException(400, f"unsafe path in archive: {m.name}")
if not (m.isfile() or m.isdir()):
raise HTTPException(400, f"unsupported entry type: {m.name}")
rel = "/".join(parts)
names.append(rel)
key = rel.split("/", 1)[1] if "/" in rel else rel
if key == "manifest.json" and manifest is None:
fobj = tf.extractfile(m)
if fobj is None:
raise HTTPException(400, "manifest.json is not a regular file")
manifest = json.loads(fobj.read().decode("utf-8"))
except HTTPException:
raise
except (tarfile.TarError, UnicodeDecodeError) as e:
raise HTTPException(400, f"invalid tar.gz archive: {e}")
except json.JSONDecodeError as e:
raise HTTPException(400, f"manifest.json is not valid JSON: {e}")
if manifest is None:
raise HTTPException(400, "manifest.json not found in archive")
if not isinstance(manifest, dict):
raise HTTPException(400, "manifest.json must be a JSON object")
for f in manifest.get("files", []) or []:
if not any(n == f or n.endswith("/" + str(f)) for n in names):
raise HTTPException(400, f"manifest.files entry missing from archive: {f}")
return manifest, names
def _get_version_row(ctx: Ctx, plugin_id: str, version: str):
r = ctx.conn.execute(
"SELECT * FROM plugin_versions WHERE plugin_id = %s AND version = %s",
(plugin_id, version),
).fetchone()
if r is None:
raise HTTPException(404, "plugin version not found")
return r
@router.get("/v1/plugins")
def list_plugins(
request: Request,
ctx: Ctx = Depends(get_ctx),
harness_version: str | None = None,
):
ctx.require_user(request)
meta = {r["id"]: dict(r) for r in ctx.conn.execute("SELECT * FROM plugins").fetchall()}
versions: dict[str, list] = {}
for r in ctx.conn.execute("SELECT * FROM plugin_versions").fetchall():
if harness_version and semver_tuple(r["min_harness_version"]) > semver_tuple(harness_version):
continue
versions.setdefault(r["plugin_id"], []).append(
{
"version": r["version"],
"channel": r["channel"],
"sha256": r["sha256"],
"size_bytes": r["size_bytes"],
"min_harness_version": r["min_harness_version"],
"published_at": r["published_at"],
}
)
plugins = []
for pid, m in meta.items():
vers = sorted(versions.get(pid, []), key=lambda v: semver_tuple(v["version"]), reverse=True)
stable = next((v for v in vers if v["channel"] == "stable"), None)
plugins.append(
{
"id": pid,
"name": m["name"],
"description": m["description"],
"versions": vers,
"latest": stable or (vers[0] if vers else None),
}
)
return {"plugins": plugins}
@router.get("/v1/plugins/{plugin_id}/{version}")
def get_plugin(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
ctx.require_user(request)
r = _get_version_row(ctx, plugin_id, version)
return {
"id": r["plugin_id"],
"version": r["version"],
"channel": r["channel"],
"sha256": r["sha256"],
"size_bytes": r["size_bytes"],
"min_harness_version": r["min_harness_version"],
"manifest": json.loads(r["manifest_json"]),
"published_at": r["published_at"],
}
@router.get("/v1/plugins/{plugin_id}/{version}/download")
def download_plugin(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
ctx.require_user(request)
r = _get_version_row(ctx, plugin_id, version)
try:
path = artifact_path(ctx.cfg, r["artifact_path"])
except ValueError:
raise HTTPException(500, "stored artifact path is invalid")
if not path.is_file():
# The row outlived its file (data dir moved, volume not mounted). Say so
# instead of 500ing, and name the path so an operator can fix it.
raise HTTPException(404, f"artifact file missing on server: {path.name}")
return FileResponse(
path,
media_type="application/gzip",
filename=f"{plugin_id}-{version}.tar.gz",
headers={"X-Sha256": r["sha256"]},
)
@router.post("/v1/plugins/{plugin_id}")
async def upload_plugin(
plugin_id: str,
request: Request,
ctx: Ctx = Depends(get_ctx),
file: UploadFile = File(...),
):
"""Admin publishes a new immutable plugin version (tar.gz, manifest.json inside)."""
admin = ctx.require_admin(request)
if not PLUGIN_ID_RE.match(plugin_id):
raise HTTPException(400, "invalid plugin id")
data = await file.read()
if not data:
raise HTTPException(400, "empty artifact")
if len(data) > ctx.cfg.max_plugin_bytes:
raise HTTPException(413, "artifact too large")
manifest, _names = _inspect_tarball(data)
version = str(manifest.get("version", ""))
if not VERSION_RE.match(version):
raise HTTPException(400, "manifest.version missing or invalid")
if str(manifest.get("id", "")) != plugin_id:
raise HTTPException(400, "manifest.id does not match the URL plugin id")
channel = manifest.get("channel", "stable")
if channel not in ("stable", "beta"):
raise HTTPException(400, "manifest.channel must be 'stable' or 'beta'")
min_hv = str(manifest.get("min_harness_version", "0.0.0"))
if ctx.conn.execute(
"SELECT 1 FROM plugin_versions WHERE plugin_id = %s AND version = %s",
(plugin_id, version),
).fetchone():
raise HTTPException(409, "this plugin version already exists")
sha = sha256_bytes(data)
dest = ctx.cfg.plugins_dir / plugin_id
dest.mkdir(parents=True, exist_ok=True)
final = dest / f"{version}.tar.gz"
tmp = dest / f".{version}.tmp"
tmp.write_bytes(data)
tmp.replace(final)
ctx.conn.execute(
"""INSERT INTO plugins (id, name, description, latest_version, updated_at)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
updated_at = excluded.updated_at""",
(plugin_id, str(manifest.get("name", plugin_id)), str(manifest.get("description", "")), version, now_iso()),
)
ctx.conn.execute(
"""INSERT INTO plugin_versions
(plugin_id, version, channel, sha256, size_bytes, manifest_json,
min_harness_version, artifact_path, published_by, published_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(plugin_id, version, channel, sha, len(data), json.dumps(manifest),
min_hv, artifact_relpath(plugin_id, version), admin["id"], now_iso()),
)
ctx.conn.commit()
record_audit(ctx.conn, admin["id"], "plugin.publish",
{"id": plugin_id, "version": version, "channel": channel, "sha256": sha})
return {"id": plugin_id, "version": version, "sha256": sha, "size_bytes": len(data)}
@router.delete("/v1/plugins/{plugin_id}/{version}")
def delete_plugin_version(plugin_id: str, version: str, request: Request, ctx: Ctx = Depends(get_ctx)):
admin = ctx.require_admin(request)
r = _get_version_row(ctx, plugin_id, version)
ctx.conn.execute(
"DELETE FROM plugin_versions WHERE plugin_id = %s AND version = %s",
(plugin_id, version),
)
remaining = ctx.conn.execute(
"SELECT COUNT(*) AS c FROM plugin_versions WHERE plugin_id = %s", (plugin_id,)
).fetchone()["c"]
if remaining == 0:
ctx.conn.execute("DELETE FROM plugins WHERE id = %s", (plugin_id,))
ctx.conn.commit()
try:
path = artifact_path(ctx.cfg, r["artifact_path"])
except ValueError:
path = None
if path and path.exists():
path.unlink()
record_audit(ctx.conn, admin["id"], "plugin.delete", {"id": plugin_id, "version": version})
return {"deleted": True, "id": plugin_id, "version": version}