From 89b83465e489e4dec555c3dd61723d941aa2d538 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 14:37:28 +0800 Subject: [PATCH] fix: add cloud web env reuse runtime --- deploy/deploy.json | 2 + deploy/runtime/boot/hwlab-cloud-web.sh | 99 ++++++++++++++++++++++++++ docs/reference/spec-v02-cicd.md | 4 ++ scripts/artifact-publish.mjs | 30 ++++++-- scripts/g14-ci-plan.test.mjs | 75 ++++++++++++++++--- scripts/g14-gitops-render.mjs | 30 ++++++-- scripts/src/g14-ci-plan-lib.mjs | 2 +- 7 files changed, 219 insertions(+), 23 deletions(-) create mode 100755 deploy/runtime/boot/hwlab-cloud-web.sh diff --git a/deploy/deploy.json b/deploy/deploy.json index 4d58a1b6..2c49e5e2 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -135,9 +135,11 @@ "runtimePath": "deploy/gitops/g14/runtime-v02", "imageTagMode": "full", "envReuseServices": [ + "hwlab-cloud-web", "hwlab-device-pod" ], "bootScripts": { + "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh", "hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh" }, "services": [ diff --git a/deploy/runtime/boot/hwlab-cloud-web.sh b/deploy/runtime/boot/hwlab-cloud-web.sh new file mode 100755 index 00000000..c7782936 --- /dev/null +++ b/deploy/runtime/boot/hwlab-cloud-web.sh @@ -0,0 +1,99 @@ +#!/bin/sh +set -eu + +export HWLAB_SERVICE_ID="hwlab-cloud-web" +export HWLAB_ARTIFACT_KIND="cloud-web" +export HWLAB_SERVICE_ENTRYPOINT="web/hwlab-cloud-web/index.html" +export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" +export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" +export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" +export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" +export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" +export PORT="${PORT:-${HWLAB_PORT:-8080}}" +export HWLAB_PORT="$PORT" + +bun_bin="${HWLAB_BUN_COMMAND:-}" +if [ -z "$bun_bin" ]; then + if command -v bun >/dev/null 2>&1; then + bun_bin="$(command -v bun)" + elif [ -x /usr/local/bin/bun ]; then + bun_bin="/usr/local/bin/bun" + elif [ -x ./node_modules/.bin/bun ]; then + bun_bin="./node_modules/.bin/bun" + else + echo "hwlab-cloud-web boot failed: bun not found" >&2 + exit 127 + fi +fi + +"$bun_bin" run --cwd web/hwlab-cloud-web build + +cat > .hwlab-cloud-web-runtime.mjs <<'NODE' +import { Buffer } from "node:buffer"; +import { serveCloudWeb } from "./internal/dev-entrypoint/cloud-web-runtime.mjs"; + +const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10); +const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-cloud-web"; +const environment = process.env.HWLAB_ENVIRONMENT || "v02"; +const roots = [ + `${process.cwd()}/web/hwlab-cloud-web/dist`, + `${process.cwd()}/web/hwlab-cloud-web` +]; + +function sendJson(response, statusCode, body) { + const payload = JSON.stringify(body, null, 2); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); +} + +function imageTagFromReference(imageReference) { + const source = String(imageReference || "").trim(); + const slashIndex = source.lastIndexOf("/"); + const colonIndex = source.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return source.slice(colonIndex + 1) || null; +} + +function shortRevision(value) { + const source = String(value || "").trim(); + return source.length >= 12 ? source.slice(0, 12) : source || "unknown"; +} + +function healthPayload() { + const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_BOOT_COMMIT || "unknown"; + const imageReference = process.env.HWLAB_IMAGE || process.env.HWLAB_ENVIRONMENT_IMAGE || "unknown"; + return { + serviceId, + environment, + status: "ok", + artifactKind: "cloud-web", + runtimeMode: process.env.HWLAB_RUNTIME_MODE || "env-reuse-git-mirror-checkout", + revision: process.env.HWLAB_REVISION || commitId, + commit: { + id: commitId, + source: process.env.HWLAB_BUILD_SOURCE || "git-mirror-runtime-checkout" + }, + image: { + reference: imageReference, + tag: imageTagFromReference(imageReference) || shortRevision(commitId), + digest: process.env.HWLAB_IMAGE_DIGEST || process.env.HWLAB_ENVIRONMENT_DIGEST || "unknown" + }, + boot: { + repo: process.env.HWLAB_BOOT_REPO || null, + commit: process.env.HWLAB_BOOT_COMMIT || null, + script: process.env.HWLAB_BOOT_SH || null + }, + build: { + source: process.env.HWLAB_BUILD_SOURCE || "runtime-boot:web-build", + metadataSource: "runtime-boot" + } + }; +} + +await serveCloudWeb({ port, serviceId, healthPayload, sendJson, roots }); +NODE + +exec "$bun_bin" .hwlab-cloud-web-runtime.mjs diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index c2208fcf..2cd9df74 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -105,6 +105,10 @@ CI/CD 内部由 UniDesk 手动触发入口、PipelineRun、component planner、B 5. affected service 通过 BuildKit 发布到 G14 本地 registry;reused service 复用 catalog digest。 所有 selected service 的 build TaskRun 都只依赖 `plan-artifacts`,不按 service 串行排队,也不设置 8 并发或其他 Pipeline 级限流。 实际并发由 Tekton controller、G14 节点资源、PVC I/O、BuildKit sidecar 和本地 registry 承载能力决定。 + `hwlab-cloud-web` 和 `hwlab-device-pod` 必须使用 `env-reuse-git-mirror-checkout`。 + 只有 package/runtime/env 输入变化时才构建 `-env` 镜像。 + 纯前端源码、Device Pod 源码或 boot code 变化只更新三变量和 GitOps desired state, + 不再重新构建业务镜像。 6. promotion 刷新 `deploy/artifact-catalog.v02.json`,render `deploy/gitops/g14/runtime-v02/**`,只推送到 `devops-infra` mirror/relay 的 `v0.2-gitops`。 7. `hwlab-g14-v02` 从本地 mirror/relay 的 `v0.2-gitops:deploy/gitops/g14/runtime-v02` 同步到 `hwlab-v02`。 8. UniDesk CLI 或 mirror/relay flush 操作把本地 `v0.2-gitops` 推送到 GitHub canonical remote;flush 不在 CI runtime-ready 的关键路径内,但必须可查询 pending、lastFlushed 和 failure。 diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 335fb7e4..b0e2a857 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -616,7 +616,7 @@ function artifactEnvironment(args) { return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV; } -function artifactCatalogSkeleton({ args, commitId }) { +function artifactCatalogSkeleton({ args, deployManifest, commitId }) { const tag = imageTagForCommit(args, commitId); return { catalogVersion: "v1", @@ -635,11 +635,11 @@ function artifactCatalogSkeleton({ args, commitId }) { }, allowedProfiles: [artifactEnvironment(args)], forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], - services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, serviceId, tag, commitId })) + services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId })) }; } -function artifactCatalogSkeletonService({ args, serviceId, tag, commitId }) { +function artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }) { const base = { serviceId, commitId: tag, @@ -657,7 +657,9 @@ function artifactCatalogSkeletonService({ args, serviceId, tag, commitId }) { artifactScope: "required", notPublishedReason: "publish_not_run" }; - if (args.lane !== "v02" || serviceId !== "hwlab-device-pod") return base; + const envReuseServiceIds = envReuseServiceIdsForLane(deployManifest, args.lane); + if (!envReuseServiceIds.has(serviceId)) return base; + const bootSh = bootShForService(deployManifest, serviceId); const environmentImage = envReuseImageRef(args.registryPrefix, serviceId, commitId); return { ...base, @@ -671,11 +673,25 @@ function artifactCatalogSkeletonService({ args, serviceId, tag, commitId }) { codeInputHash: null, bootRepo: "git@github.com:pikasTech/HWLAB.git", bootCommit: commitId, - bootSh: `deploy/runtime/boot/${serviceId}.sh`, - bootEnvEvidence: bootEnvEvidence({ service: { serviceId, bootCommit: commitId, bootSh: `deploy/runtime/boot/${serviceId}.sh` }, commitId, environmentImage, environmentDigest: "not_published" }) + bootSh, + bootEnvEvidence: bootEnvEvidence({ service: { serviceId, bootCommit: commitId, bootSh }, commitId, environmentImage, environmentDigest: "not_published" }) }; } +function envReuseServiceIdsForLane(deployManifest, lane) { + if (lane !== "v02") return new Set(); + const configured = deployManifest?.lanes?.v02?.envReuseServices; + const allowed = new Set(serviceIdsForLane(lane)); + return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId))); +} + +function bootShForService(deployManifest, serviceId) { + const value = deployManifest?.lanes?.v02?.bootScripts?.[serviceId] || `deploy/runtime/boot/${serviceId}.sh`; + const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, ""); + assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`); + return text; +} + function normalizeCatalogForLane(catalog, lane) { if (!catalog || lane !== "v02") return catalog; const allowed = new Set(serviceIdsForLane(lane)); @@ -2045,7 +2061,7 @@ async function main() { ]); let catalog = await readJsonIfPresent(args.catalogPath, null); const catalogWasMissing = !catalog; - if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, commitId }); + if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, deployManifest, commitId }); catalog = normalizeCatalogForLane(catalog, args.lane); assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`); const shortCommit = imageTagForCommit(args, commitId); diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 0264de2e..60a0235a 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -209,6 +209,51 @@ test("v02 planner marks device-pod code-only change as env reuse rollout", async assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); }); +test("v02 planner marks cloud-web code-only change as env reuse rollout", async () => { + const repo = await createFixtureRepo({ includeDevicePod: true }); + const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); + const initialPlan = await createG14CiPlan({ + repoRoot: repo, + lane: "v02", + baseRef: "HEAD", + targetRef: initialSha, + artifactCatalogPath: "deploy/artifact-catalog.v02.json", + services: ["hwlab-cloud-web"] + }); + const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); + await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { + cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, + cloudWebCodeInputHash: initialCloudWeb.codeInputHash, + cloudWebBootCommit: initialPlan.sourceCommitId + }), null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v02.json"]); + await git(repo, ["commit", "-m", "seed v02 cloud web catalog"]); + + await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "v2\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change cloud web code"]); + + const plan = await createG14CiPlan({ + repoRoot: repo, + lane: "v02", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v02.json", + services: ["hwlab-cloud-web"] + }); + const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web"); + assert.equal(cloudWeb.runtimeMode, "env-reuse-git-mirror-checkout"); + assert.equal(cloudWeb.envChanged, false); + assert.equal(cloudWeb.codeChanged, true); + assert.equal(cloudWeb.buildRequired, false); + assert.equal(cloudWeb.bootSh, "deploy/runtime/boot/hwlab-cloud-web.sh"); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]); + assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.imageBuildRequired, false); + assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout"); +}); + test("v02 planner keeps CLI and host skill changes out of unrelated service builds", async () => { const repo = await createFixtureRepo({ includeDevicePod: true }); const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); @@ -220,8 +265,12 @@ test("v02 planner keeps CLI and host skill changes out of unrelated service buil artifactCatalogPath: "deploy/artifact-catalog.v02.json", services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"] }); + const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web"); const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod"); await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", { + cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash, + cloudWebCodeInputHash: initialCloudWeb.codeInputHash, + cloudWebBootCommit: initialPlan.sourceCommitId, devicePodEnvironmentInputHash: initialDevicePod.environmentInputHash, devicePodCodeInputHash: initialDevicePod.codeInputHash, devicePodBootCommit: initialPlan.sourceCommitId @@ -292,6 +341,7 @@ async function createFixtureRepo(options = {}) { await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n"); await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n"); await writeFile(path.join(repo, "internal/device-pod/executor.ts"), "export const version = 1;\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-device-pod.sh"), "#!/bin/sh\nexec bun cmd/hwlab-device-pod/main.ts\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); @@ -306,8 +356,11 @@ function createDeployFixture({ deployServices, k3sServiceMappings, includeDevice lanes: { v02: { sourceRepo: "git@github.com:pikasTech/HWLAB.git", - envReuseServices: ["hwlab-device-pod"], - bootScripts: { "hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh" } + envReuseServices: ["hwlab-cloud-web", "hwlab-device-pod"], + bootScripts: { + "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh", + "hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh" + } } } }; @@ -339,16 +392,22 @@ function createCatalogFixture(mode, options = {}) { image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`, imageTag: "abc1234", digest, - ...(serviceId === "hwlab-device-pod" ? { + ...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? { runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, - environmentImage: "127.0.0.1:5000/hwlab/hwlab-device-pod-env:env-abc1234", + environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`, environmentDigest: digest, - environmentInputHash: options.devicePodEnvironmentInputHash ?? "e".repeat(64), - codeInputHash: options.devicePodCodeInputHash ?? "c".repeat(64), + environmentInputHash: serviceId === "hwlab-cloud-web" + ? options.cloudWebEnvironmentInputHash ?? "e".repeat(64) + : options.devicePodEnvironmentInputHash ?? "e".repeat(64), + codeInputHash: serviceId === "hwlab-cloud-web" + ? options.cloudWebCodeInputHash ?? "c".repeat(64) + : options.devicePodCodeInputHash ?? "c".repeat(64), bootRepo: "git@github.com:pikasTech/HWLAB.git", - bootCommit: options.devicePodBootCommit ?? "abc1234", - bootSh: "deploy/runtime/boot/hwlab-device-pod.sh" + bootCommit: serviceId === "hwlab-cloud-web" + ? options.cloudWebBootCommit ?? "abc1234" + : options.devicePodBootCommit ?? "abc1234", + bootSh: `deploy/runtime/boot/${serviceId}.sh` } : {}) })) }; diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 2fe4f9e7..a7692d46 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -428,6 +428,20 @@ function bootShForService(deployService, serviceId) { return text; } +function envReuseServiceIdsForLane(deploy, lane) { + if (lane !== "v02") return new Set(); + const configured = deploy?.lanes?.v02?.envReuseServices; + const allowed = new Set(serviceIdsForLane(lane)); + return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId))); +} + +function deployServiceForBoot(deploy, serviceId) { + return { + serviceId, + bootSh: deploy?.lanes?.v02?.bootScripts?.[serviceId] + }; +} + function bootMetadataForService({ args, catalog, deployService, serviceId, source }) { const catalogService = catalogServiceById(catalog, serviceId); const environmentImage = catalogService?.environmentImage ?? imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(catalogService?.environmentInputHash ?? source.full).slice(0, 12)}`); @@ -507,7 +521,7 @@ function isV02RemovedServiceId(serviceId) { return v02RemovedServiceIds.has(serviceId); } -function artifactCatalogSkeleton({ args, source }) { +function artifactCatalogSkeleton({ args, deploy, source }) { const environment = artifactEnvironment(args); const namespace = args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"; const tag = imageTagForSource(args, source); @@ -528,11 +542,11 @@ function artifactCatalogSkeleton({ args, source }) { }, allowedProfiles: [environment], forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], - services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, source, serviceId, environment, namespace, tag })) + services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag })) }; } -function artifactCatalogSkeletonService({ args, source, serviceId, environment, namespace, tag }) { +function artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }) { const base = { serviceId, profile: environment, @@ -544,7 +558,9 @@ function artifactCatalogSkeletonService({ args, source, serviceId, environment, digest: null, buildBackend: "contract-skeleton" }; - if (args.lane !== "v02" || serviceId !== "hwlab-device-pod") return base; + const envReuseServiceIds = envReuseServiceIdsForLane(deploy, args.lane); + if (!envReuseServiceIds.has(serviceId)) return base; + const bootSh = bootShForService(deployServiceForBoot(deploy, serviceId), serviceId); const environmentImage = imageFor(args.registryPrefix, `${serviceId}-env`, `env-${String(source.full).slice(0, 12)}`); return { ...base, @@ -557,13 +573,13 @@ function artifactCatalogSkeletonService({ args, source, serviceId, environment, environmentInputHash: null, bootRepo: args.sourceRepo, bootCommit: source.full, - bootSh: `deploy/runtime/boot/${serviceId}.sh`, + bootSh, codeInputHash: null, bootEnvEvidence: { env: { HWLAB_BOOT_REPO: args.sourceRepo, HWLAB_BOOT_COMMIT: source.full, - HWLAB_BOOT_SH: `deploy/runtime/boot/${serviceId}.sh` + HWLAB_BOOT_SH: bootSh } } }; @@ -4226,7 +4242,7 @@ async function plannedFiles(args) { const source = await resolveSourceRevision(args.sourceRevision); source.imageTag = imageTagForSource(args, source); let artifactCatalog = await readJsonIfPresent(args.catalogPath, null); - if (!artifactCatalog && args.lane === "v02") artifactCatalog = artifactCatalogSkeleton({ args, source }); + if (!artifactCatalog && args.lane === "v02") artifactCatalog = artifactCatalogSkeleton({ args, deploy, source }); if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath); const settings = ciLaneSettings(args); const profiles = laneRuntimeProfiles(args); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index c2af4633..50bcaace 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -339,7 +339,7 @@ async function readJsonFromGit(repoRoot, ref, filePath, fallback) { export function enabledEnvReuseServices(deployJson, lane, serviceIds) { if (lane !== "v02") return new Set(); const configured = deployJson?.lanes?.v02?.envReuseServices; - const values = Array.isArray(configured) ? configured : ["hwlab-device-pod"]; + const values = Array.isArray(configured) ? configured : []; const allowed = new Set(serviceIds); return new Set(values.filter((serviceId) => allowed.has(serviceId))); }