feat: DSH编排基建首版(server/tests 88用例/P2插件/8020端口/PORT-NOTE/MOBILE_APP)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user