diff --git a/gitops/reuse.ymal b/gitops/reuse.ymal index 9400b54f..02c0fd1e 100644 --- a/gitops/reuse.ymal +++ b/gitops/reuse.ymal @@ -5,6 +5,15 @@ metadata: ownerRepository: pikasTech/HWLAB spec: envArtifactGroups: + hwlab-go-runtime-env: + enabled: true + mode: env-reuse-git-mirror-checkout + buildService: hwlab-user-billing + imageRepository: hwlab-go-runtime-env + cacheRepository: cache/hwlab-go-runtime-env + services: + - hwlab-user-billing + - hwlab-workbench-runtime hwlab-ts-runtime-env: enabled: true mode: env-reuse-git-mirror-checkout diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 3fa1e733..e1e35fce 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -1145,6 +1145,79 @@ test("v03 planner reads env reuse declarations and catalog from v03 lane config" } }); +test("v03 planner builds one shared Go env artifact for compatible Go services", async () => { + const repo = await createFixtureRepo({ catalog: false }); + await addGoEnvReuseFixture(repo); + await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.2\n"); + await git(repo, ["add", "go.mod"]); + await git(repo, ["commit", "-m", "change go env dependency"]); + + const plan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", + services: ["hwlab-user-billing", "hwlab-workbench-runtime"] + }); + + const group = plan.envArtifactGroups.find((item) => item.id === "hwlab-go-runtime-env"); + assert.equal(group.buildRequired, true); + assert.deepEqual(group.buildServices, ["hwlab-user-billing"]); + assert.deepEqual(group.consumerServices, ["hwlab-workbench-runtime"]); + assert.deepEqual(plan.buildServices, ["hwlab-user-billing"]); + const userBilling = plan.services.find((service) => service.serviceId === "hwlab-user-billing"); + const workbenchRuntime = plan.services.find((service) => service.serviceId === "hwlab-workbench-runtime"); + assert.equal(userBilling.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env"); + assert.equal(workbenchRuntime.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env"); + assert.equal(userBilling.environmentImage, `127.0.0.1:5000/hwlab/hwlab-go-runtime-env:env-${userBilling.environmentInputHash.slice(0, 12)}`); + assert.equal(workbenchRuntime.environmentImage, userBilling.environmentImage); + assert.equal(workbenchRuntime.sharedEnvBuildSkipped, true); + assert.equal(workbenchRuntime.buildRequired, false); +}); + +test("v03 planner ignores Go launcher comment-only changes for env identity", async () => { + const repo = await createFixtureRepo({ catalog: false }); + await addGoEnvReuseFixture(repo); + const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); + const initialPlan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD", + targetRef: initialSha, + artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json", + services: ["hwlab-user-billing", "hwlab-workbench-runtime"] + }); + await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { + sourceCommitId: initialSha, + serviceIds: ["hwlab-user-billing", "hwlab-workbench-runtime"] + })), null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); + await git(repo, ["commit", "-m", "seed go env reuse catalog"]); + + await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// changed explanatory comment\nexport const goEnvLauncher = true;\n"); + await git(repo, ["add", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"]); + await git(repo, ["commit", "-m", "change go launcher comment only"]); + + const plan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: ["hwlab-user-billing", "hwlab-workbench-runtime"] + }); + + assert.deepEqual(plan.affectedServices, []); + assert.deepEqual(plan.buildServices, []); + assert.equal(plan.imageBuildRequired, false); + for (const service of plan.services) { + assert.equal(service.runtimeReuseDecision.envIdentityHit, true, service.serviceId); + assert.equal(service.environmentInputChanged, false, service.serviceId); + assert.equal(service.buildRequired, false, service.serviceId); + } +}); + async function createFixtureRepo(options = {}) { const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"]; const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS; @@ -1255,6 +1328,84 @@ function planV02CoreServices(repo, options) { }); } +async function addGoEnvReuseFixture(repo) { + await mkdir(path.join(repo, "cmd/hwlab-user-billing"), { recursive: true }); + await mkdir(path.join(repo, "cmd/hwlab-workbench-runtime"), { recursive: true }); + await mkdir(path.join(repo, "internal/userbilling"), { recursive: true }); + await mkdir(path.join(repo, "internal/workbenchruntime"), { recursive: true }); + await mkdir(path.join(repo, "gitops"), { recursive: true }); + await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.1\n"); + await writeFile(path.join(repo, "go.sum"), "example.com/direct v0.0.1 h1:fixture\n"); + await writeFile(path.join(repo, "cmd/hwlab-user-billing/main.go"), "package main\n\nfunc main() {}\n"); + await writeFile(path.join(repo, "cmd/hwlab-workbench-runtime/main.go"), "package main\n\nfunc main() {}\n"); + await writeFile(path.join(repo, "internal/userbilling/billing.go"), "package userbilling\n"); + await writeFile(path.join(repo, "internal/workbenchruntime/runtime.go"), "package workbenchruntime\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-user-billing.sh"), "#!/bin/sh\nexec /app/hwlab-user-billing\n"); + await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-workbench-runtime.sh"), "#!/bin/sh\nexec /app/hwlab-workbench-runtime\n"); + await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// initial explanatory comment\nexport const goEnvLauncher = true;\n"); + const deployPath = path.join(repo, "deploy/deploy.yaml"); + const deploy = await readStructuredFile(repo, deployPath); + deploy.lanes.v03.envReuseServices = uniquePreserveOrder([ + ...deploy.lanes.v03.envReuseServices, + "hwlab-user-billing", + "hwlab-workbench-runtime" + ]); + deploy.lanes.v03.bootScripts["hwlab-user-billing"] = "deploy/runtime/boot/hwlab-user-billing.sh"; + deploy.lanes.v03.bootScripts["hwlab-workbench-runtime"] = "deploy/runtime/boot/hwlab-workbench-runtime.sh"; + deploy.lanes.v03.serviceDeclarations["hwlab-user-billing"] = { + runtimeKind: "go-service", + entrypoint: "cmd/hwlab-user-billing/main.go", + artifactKind: "go-service", + healthPath: "/health/live", + healthPort: 7101, + componentPaths: ["cmd/hwlab-user-billing/", "internal/userbilling/"], + env: {}, + observable: true + }; + deploy.lanes.v03.serviceDeclarations["hwlab-workbench-runtime"] = { + runtimeKind: "go-service", + entrypoint: "cmd/hwlab-workbench-runtime/main.go", + artifactKind: "go-service", + healthPath: "/health/live", + healthPort: 7102, + componentPaths: ["cmd/hwlab-workbench-runtime/", "internal/workbenchruntime/"], + env: {}, + observable: true + }; + await writeStructuredFile(repo, deployPath, deploy); + await writeStructuredFile(repo, "gitops/reuse.ymal", { + apiVersion: "unidesk.pikapython.com/v1alpha1", + kind: "RuntimeReuseConfig", + metadata: { name: "hwlab-v03-reuse", ownerRepository: "pikasTech/HWLAB" }, + spec: { + envArtifactGroups: { + "hwlab-go-runtime-env": { + enabled: true, + mode: "env-reuse-git-mirror-checkout", + buildService: "hwlab-user-billing", + imageRepository: "hwlab-go-runtime-env", + cacheRepository: "cache/hwlab-go-runtime-env", + services: ["hwlab-user-billing", "hwlab-workbench-runtime"] + } + }, + services: Object.fromEntries(["hwlab-user-billing", "hwlab-workbench-runtime"].map((serviceId) => [serviceId, { + runtimeReuse: { + enabled: true, + codeIdentity: { paths: [serviceId === "hwlab-user-billing" ? "cmd/hwlab-user-billing" : "cmd/hwlab-workbench-runtime"] }, + envIdentity: { paths: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"] } + }, + envReuse: { + enabled: true, + mode: "env-reuse-git-mirror-checkout", + envIdentityFiles: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"] + } + }])) + } + }); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "add go env reuse fixture"]); +} + function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) { const v02 = { sourceRepo: "git@github.com:pikasTech/HWLAB.git", @@ -1380,6 +1531,11 @@ function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) { function createCatalogFixture(mode, options = {}) { const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published"; + const envReuseCatalogServiceIds = new Set([ + ...V02_RUNTIME_SERVICE_IDS, + ...Object.keys(options.environmentInputHashes ?? {}), + ...Object.keys(options.codeInputHashes ?? {}) + ]); const serviceIds = options.serviceIds ?? uniquePreserveOrder([ "hwlab-cloud-api", "hwlab-cloud-web", @@ -1398,7 +1554,7 @@ function createCatalogFixture(mode, options = {}) { ...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? { componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId] } : {}), - ...(V02_RUNTIME_SERVICE_IDS.includes(serviceId) ? { + ...(envReuseCatalogServiceIds.has(serviceId) ? { runtimeMode: "env-reuse-git-mirror-checkout", envReuse: true, environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`, diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index b44aa07d..35819a8b 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -25,6 +25,10 @@ export const GO_RUNTIME_DEP_PATHS = Object.freeze([ "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs" ]); +const COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS = new Set([ + "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs" +]); + export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([ "dependencies", "optionalDependencies", @@ -629,12 +633,25 @@ export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef result.add(filePath); continue; } + if (COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(filePath)) { + if (await envIdentityTextChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath); + continue; + } if (filePath !== "package.json") continue; if (await packageRuntimeFieldsChanged(repoRoot, baseRef, targetRef, filePath)) result.add(filePath); } return result; } +async function envIdentityTextChanged(repoRoot, baseRef, targetRef, filePath) { + const [before, after] = await Promise.all([ + readTextFromGit(repoRoot, baseRef, filePath, null), + readTextFromGit(repoRoot, targetRef, filePath, null) + ]); + if (before === null || after === null) return true; + return stableJson(normalizeEnvIdentityText(filePath, before)) !== stableJson(normalizeEnvIdentityText(filePath, after)); +} + async function serviceRuntimeConfigChanged(repoRoot, deployConfigPath, baseRef, targetRef, lane, serviceId) { const [before, after] = await Promise.all([ readDeployConfigFromGit(repoRoot, baseRef, deployConfigPath), @@ -690,13 +707,21 @@ function pickRuntimePackageJson(packageJson) { async function readJsonFromGit(repoRoot, ref, filePath, fallback) { try { - const value = await gitValue(repoRoot, ["show", `${ref}:${filePath}`]); + const value = await readTextFromGit(repoRoot, ref, filePath, ""); return JSON.parse(value); } catch { return fallback; } } +async function readTextFromGit(repoRoot, ref, filePath, fallback) { + try { + return await gitValue(repoRoot, ["show", `${ref}:${filePath}`]); + } catch { + return fallback; + } +} + export function enabledEnvReuseServices(deployJson, lane, serviceIds) { const configured = deployJson?.lanes?.[lane]?.envReuseServices; const values = Array.isArray(configured) ? configured : []; @@ -925,6 +950,10 @@ export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) { const packageJson = await readJsonFromGit(repoRoot, targetRef, filePath, null); return `100644 blob ${stableHash(pickRuntimePackageJson(packageJson))}\t${filePath}`; } + if (COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(filePath)) { + const source = await readTextFromGit(repoRoot, targetRef, filePath, ""); + return `100644 blob ${stableHash(normalizeEnvIdentityText(filePath, source))}\t${filePath}`; + } return line; }))) .filter(Boolean) @@ -932,6 +961,19 @@ export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) { return stableHash({ entries: lines }); } +function normalizeEnvIdentityText(filePath, source) { + const normalizedPath = normalizeRepoPath(filePath); + const text = String(source ?? "").replace(/\r\n?/gu, "\n"); + if (!COMMENT_INSENSITIVE_ENV_IDENTITY_PATHS.has(normalizedPath)) return text; + return text + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("//"); + }) + .join("\n"); +} + async function hashEnvReuseInputs(repoRoot, targetRef, paths, recipe, options = {}) { return stableHash({ gitPaths: await hashGitPaths(repoRoot, targetRef, paths, options),