Unverified Commit e0bc2884 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): implement image build pipeline and admin whitelist with mock dev api

parent 19f500b5
Loading
Loading
Loading
Loading
+222 −10
Changes for panel/dev/mockApi.ts: 222 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import type { Plugin } from "vite";
import type {
  AutostartPolicy,
  BackupView,
  Build,
  CreateServerRequest,
  FleetServer,
  Identity,
@@ -16,7 +17,7 @@ const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;

type AccountID = (typeof ACCOUNT_IDS)[number];
type Role = "admin" | "user";
type Method = "GET" | "POST";
type Method = "GET" | "POST" | "DELETE";
type CreateError =
  | "bad_request"
  | "already_exists"
@@ -47,14 +48,9 @@ interface MockState {
  accounts: Record<AccountID, MockAccount>;
  servers: MockServer[];
  images: WhitelistImage[];
  // Per-server §access state, keyed by server name. Lazily created (accessFor) so a
  // server only gets an entry once its access is touched; "survival" is pre-seeded
  // so the whitelist panel demos a populated list out of the box.
  access: Record<string, AccessState>;
  // World backups (GET /backups). Global, not keyed by server — the page filters by
  // server_name client-side, mirroring the real global list endpoint. Scoped per
  // caller at dispatch (admin sees all; a user only worlds they formerly owned).
  backups: BackupView[];
  builds: Build[];
}

// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -242,6 +238,25 @@ function initialState(): MockState {
      },
    },
    backups: mockBackups(),
    builds: [
      {
        id: "bld-1",
        image_ref: "registry.felis.svc:5000/modpack-beta:1.0",
        status: "succeeded",
        requested_by: "[email protected]",
        created_at: new Date(Date.now() - 3600000).toISOString(),
        finished_at: new Date(Date.now() - 3500000).toISOString(),
      },
      {
        id: "bld-2",
        image_ref: "registry.felis.svc:5000/forge-broken:1.0",
        status: "failed",
        error: "trivy found a CRITICAL CVE: CVE-2026-12345 in library/forge",
        requested_by: "[email protected]",
        created_at: new Date(Date.now() - 1800000).toISOString(),
        finished_at: new Date(Date.now() - 1700000).toISOString(),
      },
    ],
  };
}

@@ -535,9 +550,6 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
      ctx.account.mustChangePassword = false;
      sendJSON(ctx.res, 200, { ok: true });
      return true;
    case "GET images":
      sendJSON(ctx.res, 200, { images: ctx.state.images });
      return true;
    case "GET backups":
      // Admin sees every archive; a user only worlds they formerly owned — mirrors
      // AllBackups vs BackupsForUser. The panel filters by server_name client-side.
@@ -557,10 +569,210 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
      await verifyLinkRoute(ctx);
      return true;
    default:
      if (await handleImageRoute(ctx)) return true;
      return await handleServerRoute(ctx);
  }
}

async function handleImageRoute(ctx: SessionContext): Promise<boolean> {
  if (ctx.parts[2] !== "images") return false;

  // GET /api/v1/images
  if (is("GET", ctx) && ctx.parts.length === 3) {
    sendJSON(ctx.res, 200, { images: ctx.state.images });
    return true;
  }

  // POST /api/v1/images (add image)
  if (is("POST", ctx) && ctx.parts.length === 3) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const body = await readJSON<{ image_ref?: string }>(ctx.req);
    const ref = body.image_ref?.trim();
    if (!ref) {
      sendError(ctx.res, 400, "bad_request", "image_ref is required");
      return true;
    }
    // Check if already exists in whitelist
    let img = ctx.state.images.find((i) => i.image_ref === ref);
    if (img) {
      img.enabled = true;
    } else {
      img = { image_ref: ref, enabled: true, source: "external" };
      ctx.state.images.unshift(img);
    }
    sendJSON(ctx.res, 201, img);
    return true;
  }

  // DELETE /api/v1/images (remove image)
  if (is("DELETE", ctx) && ctx.parts.length === 3) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const url = new URL(ctx.req.url ?? "/", "http://localhost");
    const ref = url.searchParams.get("ref");
    if (!ref) {
      sendError(ctx.res, 400, "bad_request", "ref query parameter is required");
      return true;
    }
    const idx = ctx.state.images.findIndex((i) => i.image_ref === ref);
    if (idx < 0) {
      sendError(ctx.res, 404, "not_found", "image not found");
      return true;
    }
    ctx.state.images.splice(idx, 1);
    ctx.res.statusCode = 204;
    ctx.res.end();
    return true;
  }

  // POST /api/v1/images/build (trigger build)
  if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const body = await readJSON<{ image_ref?: string; dockerfile?: string; context_ref?: string; base_image?: string }>(ctx.req);
    if (!body.image_ref || !body.dockerfile || !body.context_ref) {
      sendError(ctx.res, 400, "bad_request", "image_ref, dockerfile, and context_ref are required");
      return true;
    }
    const newBuild: Build = {
      id: `bld-${Date.now()}`,
      image_ref: body.image_ref.trim(),
      status: "building",
      dockerfile: body.dockerfile,
      context_ref: body.context_ref.trim(),
      base_image: body.base_image?.trim(),
      requested_by: ctx.account.email,
      created_at: new Date().toISOString(),
    };
    ctx.state.builds.unshift(newBuild);

    // Mock build progression in a timeout
    setTimeout(() => {
      const b = ctx.state.builds.find((x) => x.id === newBuild.id);
      if (b && b.status === "building") {
        b.status = "succeeded";
        b.finished_at = new Date().toISOString();
        // Add to whitelist images
        if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
          ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
        }
      }
    }, 15000); // Succeeded after 15 seconds

    sendJSON(ctx.res, 202, newBuild);
    return true;
  }

  // GET /api/v1/images/build (list builds)
  if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts.length === 4) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    sendJSON(ctx.res, 200, { builds: ctx.state.builds });
    return true;
  }

  // GET /api/v1/images/build/{id} (get build)
  if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[4] && ctx.parts.length === 5) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const build = ctx.state.builds.find((b) => b.id === ctx.parts[4]);
    if (!build) {
      sendError(ctx.res, 404, "not_found", "build not found");
      return true;
    }
    sendJSON(ctx.res, 200, build);
    return true;
  }

  // POST /api/v1/images/build/{id}/cancel (cancel build)
  if (is("POST", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "cancel" && ctx.parts.length === 6) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const buildID = ctx.parts[4];
    const build = ctx.state.builds.find((b) => b.id === buildID);
    if (!build) {
      sendError(ctx.res, 404, "not_found", "build not found");
      return true;
    }
    if (build.status === "succeeded" || build.status === "failed" || build.status === "cancelled") {
      sendError(ctx.res, 409, "already_terminal", "build already terminal");
      return true;
    }
    build.status = "cancelled";
    build.finished_at = new Date().toISOString();
    sendJSON(ctx.res, 200, build);
    return true;
  }

  // GET /api/v1/images/build/{id}/logs (SSE logs stream)
  if (is("GET", ctx) && ctx.parts[3] === "build" && ctx.parts[5] === "logs" && ctx.parts.length === 6) {
    if (ctx.account.role !== "admin") {
      sendError(ctx.res, 403, "forbidden", "admin account required");
      return true;
    }
    const buildID = ctx.parts[4];
    const build = ctx.state.builds.find((b) => b.id === buildID);
    if (!build) {
      sendError(ctx.res, 404, "not_found", "build not found");
      return true;
    }
    streamBuildLogs(ctx.req, ctx.res, buildID);
    return true;
  }

  return false;
}

function streamBuildLogs(
  req: IncomingMessage,
  res: ServerResponse,
  buildID: string
): void {
  const lines = [
    `[INFO] [Kaniko] Starting build for ID: ${buildID}`,
    "[INFO] [Kaniko] Pulling base image library/postgres:15",
    "[INFO] [Kaniko] Successfully pulled base image",
    "[INFO] [Kaniko] Executing: RUN echo 'setup'",
    "[INFO] [Kaniko] Pushing image to registry.felis.svc:5000",
    "[INFO] [Trivy] Starting security scan...",
    "[INFO] [Trivy] Scanning registry.felis.svc:5000/image",
    "[INFO] [Trivy] No critical vulnerabilities found. Scan PASSED.",
    `[INFO] [System] Build succeeded for ${buildID}`,
  ];
  let i = 0;

  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  res.write(": connected\n\n");

  const timer = setInterval(() => {
    if (i < lines.length) {
      res.write(`data: ${lines[i]}\n\n`);
      i++;
    } else {
      clearInterval(timer);
    }
  }, 1000);

  req.on("close", () => clearInterval(timer));
}

async function createServerRoute(ctx: SessionContext): Promise<void> {
  if (ctx.account.role !== "admin") {
    sendError(ctx.res, 403, "forbidden", "admin account required");
+2 −0
Changes for panel/src/App.tsx: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@ import { ServerPlayers } from "@/pages/ServerPlayers";
import { ServerBackups } from "@/pages/ServerBackups";
import { Account } from "@/pages/Account";
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";

// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
//   /        User-Side    — app-tier, every authenticated principal
@@ -51,6 +52,7 @@ export default function App() {
              <Route path="admin" element={<RequireAdmin />}>
                <Route index element={<Navigate to="/admin/images" replace />} />
                <Route path="images" element={<ImageAdmin />} />
                <Route path="builds" element={<ImageBuildPage />} />
              </Route>

              <Route path="*" element={<Navigate to="/" replace />} />
+39 −2
Changes for panel/src/i18n/resources/en-US/admin.json: 39 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -8,12 +8,49 @@
  "images_desc": "The platform image whitelist — the value space the create form draws from.",
  "server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
  "images_title": "Images",
  "images_subtitle": "The platform image whitelist. Only enabled images can back a new server. Editing the whitelist from the Web is auditable — the mutation routes are a pending admin-tier slice, so this view is read-only for now.",
  "images_subtitle": "The platform image whitelist. Only enabled images can back a new server. You can add external images or delete unwanted images from the whitelist.",
  "add_image_title": "Add External Image",
  "add_image_btn": "Add Image",
  "image_ref_placeholder": "e.g. registry.felis.svc:5000/image:tag",
  "delete_image_tooltip": "Delete Whitelist Image",
  "delete_confirm": "Are you sure you want to delete this image?",
  "cancel_confirm": "Are you sure you want to cancel this build job?",
  "delete_btn": "Delete",
  "builds_title": "Build Pipeline",
  "builds_subtitle": "History of image build jobs and trigger controls. The build pod runs inside an isolated namespace with automated Trivy scans.",
  "trigger_build_title": "Trigger New Image Build",
  "trigger_build_btn": "Start Build",
  "dockerfile_label": "Dockerfile Content",
  "dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
  "context_ref_label": "Context Reference",
  "context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
  "base_image_label": "Base Image",
  "base_image_placeholder": "e.g. library/postgres:15",
  "build_history_title": "Build History",
  "view_logs_btn": "Logs",
  "cancel_build_btn": "Cancel",
  "no_builds_title": "No Builds Found",
  "no_builds_hint": "You can trigger your first image build task using the form on the top right.",
  "build_log_title": "Build Log Terminal",
  "log_streaming": "Streaming...",
  "log_finished": "Finished",
  "no_images_title": "No images whitelisted",
  "no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
  "enabled": "enabled",
  "disabled": "disabled",
  "footer_kubectl": "kubectl / CRD operations",
  "footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
  "footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power."
  "footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power.",
  "table_ref": "Image Reference",
  "table_source": "Source",
  "table_status": "Status",
  "table_action": "Action",
  "table_build_id": "Build ID",
  "table_requester": "Requester",
  "table_created_at": "Created At",
  "table_duration": "Duration",
  "filter_all": "All",
  "filter_enabled": "Enabled",
  "filter_disabled": "Disabled",
  "image_ref_label": "Image Reference"
}
 No newline at end of file
+2 −1
Changes for panel/src/i18n/resources/en-US/navigation.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,5 +3,6 @@
  "my_servers": "Servers",
  "account": "Account",
  "admin_section": "Admin",
  "admin_images": "Images"
  "admin_images": "Images",
  "admin_builds": "Build Pipeline"
}
+39 −2
Changes for panel/src/i18n/resources/zh-CN/admin.json: 39 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -8,12 +8,49 @@
  "images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
  "server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
  "images_title": "镜像",
  "images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。Web 端编辑白名单可审计——变更接口待后续上线,当前仅可查看。",
  "images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。您可以添加外部镜像,或将不需要的镜像从白名单中删除。",
  "add_image_title": "添加外部镜像",
  "add_image_btn": "添加镜像",
  "image_ref_placeholder": "例如: registry.felis.svc:5000/image:tag",
  "delete_image_tooltip": "删除白名单镜像",
  "delete_confirm": "确定删除此镜像吗?",
  "cancel_confirm": "确定要取消该构建任务吗?",
  "delete_btn": "删除",
  "builds_title": "构建流水线",
  "builds_subtitle": "平台镜像构建任务历史及触发终端。构建 Pod 运行在隔离沙箱中,完成构建后将自动运行安全扫描。",
  "trigger_build_title": "触发新镜像构建",
  "trigger_build_btn": "开始构建",
  "dockerfile_label": "Dockerfile 内容",
  "dockerfile_placeholder": "FROM library/postgres:15\nRUN echo 'setup'",
  "context_ref_label": "构建上下文引用",
  "context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
  "base_image_label": "基础镜像",
  "base_image_placeholder": "例如: library/postgres:15",
  "build_history_title": "构建历史",
  "view_logs_btn": "日志",
  "cancel_build_btn": "取消",
  "no_builds_title": "暂无构建任务",
  "no_builds_hint": "您可以使用右上角表单触发第一个镜像构建任务。",
  "build_log_title": "构建日志终端",
  "log_streaming": "实时输出中",
  "log_finished": "已结束",
  "no_images_title": "无白名单镜像",
  "no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
  "enabled": "已启用",
  "disabled": "已禁用",
  "footer_kubectl": "kubectl / CRD",
  "footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
  "footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。"
  "footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。",
  "table_ref": "镜像名称",
  "table_source": "来源",
  "table_status": "状态",
  "table_action": "操作",
  "table_build_id": "构建 ID",
  "table_requester": "发起人",
  "table_created_at": "创建时间",
  "table_duration": "耗时",
  "filter_all": "全部",
  "filter_enabled": "已启用",
  "filter_disabled": "已禁用",
  "image_ref_label": "镜像引用"
}
 No newline at end of file
Loading