diff --git a/.gitignore b/.gitignore index 9cf004b1..ab8eb357 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # G14 generated GitOps desired state is stored on the G14-gitops branch. /deploy/gitops/g14/ + +# v0.2 generated artifact catalog is stored only on the v0.2-gitops branch. +/deploy/artifact-catalog.v02.json diff --git a/deploy/deploy.json b/deploy/deploy.json index 356f1918..0c560d93 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -184,6 +184,31 @@ "notes": "Reserved placeholder only. PROD deployment and acceptance are explicitly out of scope." } }, + "lanes": { + "v02": { + "name": "v0.2", + "sourceBranch": "v0.2", + "gitopsBranch": "v0.2-gitops", + "namespace": "hwlab-v02", + "endpoint": "http://74.48.78.17:19667", + "publicEndpoints": { + "frontend": "http://74.48.78.17:19666", + "api": "http://74.48.78.17:19667" + }, + "artifactCatalog": "deploy/artifact-catalog.v02.json", + "runtimePath": "deploy/gitops/g14/runtime-v02", + "imageTagMode": "full", + "services": [ + { + "serviceId": "hwlab-cloud-api", + "env": { + "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-v02-db/database-url", + "HWLAB_CLOUD_DB_CONTRACT": "v02-redacted-presence-only" + } + } + ] + } + }, "services": [ { "serviceId": "hwlab-cloud-api", diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index a61e1c38..83faf2e9 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -26,6 +26,7 @@ import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs"; +const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14"; const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null; @@ -33,8 +34,8 @@ const defaultReportPath = tempReportPath("dev-artifacts.json"); const buildBackend = "buildkit"; const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh"; const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null; -const catalogPath = "deploy/artifact-catalog.dev.json"; -const deployPath = "deploy/deploy.json"; +const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; +const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json"; const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u; @@ -76,6 +77,10 @@ const servicePorts = new Map([ function parseArgs(argv) { const args = { mode: "preflight", + lane: defaultLane, + catalogPath: defaultCatalogPath, + deployPath: defaultDeployPath, + imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", registryPrefix: defaultRegistryPrefix, baseImage: defaultBaseImage, reportPath: defaultReportPath, @@ -98,6 +103,10 @@ function parseArgs(argv) { if (arg === "--preflight") args.mode = "preflight"; else if (arg === "--build") args.mode = "build"; else if (arg === "--publish") args.mode = "publish"; + else if (arg === "--lane") args.lane = readOption(argv, ++index, arg); + else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg); + else if (arg === "--deploy-json") args.deployPath = readOption(argv, ++index, arg); + else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg); else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg); else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg); @@ -118,6 +127,9 @@ function parseArgs(argv) { else throw new Error(`unknown argument ${arg}`); } + assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`); + assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); + return args; } @@ -191,6 +203,10 @@ function printHelp() { "Commit-pinned artifact build/publish helper for controlled CI execution.", "", "options:", + " --lane LANE g14 or v02; default: g14", + ` --catalog-path PATH default: ${defaultCatalogPath}`, + ` --deploy-json PATH default: ${defaultDeployPath}`, + " --image-tag-mode MODE short or full; v02 uses full source commit IDs", " --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab", " --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local", " --services LIST comma-separated service IDs; default: all frozen DEV services", @@ -211,6 +227,15 @@ async function readJson(relativePath) { return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); } +async function readJsonIfPresent(relativePath, fallback = null) { + try { + return await readJson(relativePath); + } catch (error) { + if (error?.code === "ENOENT") return fallback; + throw error; + } +} + function emit(event, payload = {}) { process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`); } @@ -514,6 +539,82 @@ function assertDevOnlyContracts(catalog, deployManifest) { } } +function laneDeployConfig(deployManifest, lane) { + if (lane === "v02") return deployManifest?.lanes?.v02 ?? null; + return null; +} + +function assertV02Contracts(catalog, deployManifest) { + const lane = laneDeployConfig(deployManifest, "v02"); + assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 artifact publish"); + assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02"); + assert.equal(lane.endpoint, "http://74.48.78.17:19667", "deploy.lanes.v02.endpoint must be the v02 API endpoint"); + assert.equal(catalog.environment, "v02", "catalog.environment must be v02"); + assert.equal(catalog.profile, "v02", "catalog.profile must be v02"); + assert.equal(catalog.namespace, "hwlab-v02", "catalog.namespace must be hwlab-v02"); + assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog.allowedProfiles must only allow v02"); + assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod"); + const catalogIds = (catalog.services ?? []).map((service) => service.serviceId); + assert.deepEqual(catalogIds, SERVICE_IDS, "v02 catalog services must match frozen service IDs"); + for (const service of catalog.services ?? []) { + assert.equal(service.profile, "v02", `${service.serviceId}.profile must be v02`); + assert.equal(service.namespace, "hwlab-v02", `${service.serviceId}.namespace must be hwlab-v02`); + assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`); + } +} + +function assertArtifactContracts(args, catalog, deployManifest) { + if (args.lane === "v02") return assertV02Contracts(catalog, deployManifest); + return assertDevOnlyContracts(catalog, deployManifest); +} + +function imageTagForCommit(args, commitId) { + return args.imageTagMode === "full" ? commitId : commitId.slice(0, 7); +} + +function artifactEnvironment(args) { + return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV; +} + +function artifactCatalogSkeleton({ args, commitId }) { + const tag = imageTagForCommit(args, commitId); + return { + catalogVersion: "v1", + kind: "hwlab-artifact-catalog", + environment: artifactEnvironment(args), + profile: artifactEnvironment(args), + namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev", + endpoint: args.lane === "v02" ? "http://74.48.78.17:19667" : "http://74.48.78.17:16667", + commitId: tag, + artifactState: "contract-skeleton", + publish: { + ciPublished: false, + registryVerified: false, + provenance: "not_available_until_publish", + note: `${artifactEnvironment(args)} artifact catalog skeleton initialized without DEV/G14 fallback.` + }, + allowedProfiles: [artifactEnvironment(args)], + forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], + services: SERVICE_IDS.map((serviceId) => ({ + serviceId, + commitId: tag, + sourceCommitId: commitId, + image: imageRef(args.registryPrefix, serviceId, tag), + imageTag: tag, + digest: "not_published", + publishState: "skeleton-only", + profile: artifactEnvironment(args), + namespace: args.lane === "v02" ? "hwlab-v02" : "hwlab-dev", + healthPath: "/health/live", + sourceState: "source-present", + publishEnabled: true, + artifactRequired: true, + artifactScope: "required", + notPublishedReason: "publish_not_run" + })) + }; +} + function parseRegistryPrefix(prefix) { const normalized = prefix.replace(/\/+$/u, ""); const [hostPort, ...pathParts] = normalized.split("/"); @@ -1146,14 +1247,14 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre } try { - assertDevOnlyContracts(catalog, deployManifest); + assertArtifactContracts(args, catalog, deployManifest); } catch (error) { blockers.push( blocker({ type: "contract_blocker", - scope: "dev-contract", + scope: `${args.lane}-contract`, summary: error.message, - next: "Fix deploy/artifact-catalog.dev.json and deploy/deploy.json so both remain DEV-only and cover the frozen service IDs." + next: `Fix ${args.catalogPath} and ${args.deployPath} so the selected artifact lane covers the frozen service IDs without crossing namespaces.` }) ); } @@ -1854,18 +1955,20 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length; const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length; const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length; + const environment = artifactEnvironment(args); return { $schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json", - $id: "https://hwlab.pikastech.local/g14-cicd/artifacts.json", + $id: `https://hwlab.pikastech.local/${args.lane}-cicd/artifacts.json`, reportVersion: "v1", - issue: "pikasTech/HWLAB#35", - taskId: "g14-artifact-publish", + issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35", + taskId: `${args.lane}-artifact-publish`, commitId: shortCommit, - acceptanceLevel: "dev_artifact_publish", - devOnly: true, + acceptanceLevel: `${environment}_artifact_publish`, + lane: args.lane, + devOnly: args.lane !== "v02", prodDisabled: true, - reportLifecycle: activeReportLifecycle("Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute."), + reportLifecycle: activeReportLifecycle(`Current ${environment} artifact publish report; it is not runtime acceptance evidence.`), sourceContract: { status: fatalBlocked ? "blocked" : "pass", documents: [ @@ -1875,7 +1978,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif "docs/reference/g14-gitops-cicd.md", "docs/dev-base-image-preflight.md" ], - summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope." + summary: `${environment} artifact publish evidence; runtime acceptance stays separate from artifact publication.` }, validationCommands: [ "node --check scripts/artifact-publish.mjs", @@ -1916,19 +2019,22 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif requirements: [ "Registry prefix is localhost or private/internal only.", "DEV builder base image preflight returns ready before build or publish.", - "Environment remains dev and namespace remains hwlab-dev.", + `Environment remains ${environment} and namespace remains ${args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"}.`, "PROD profile remains disabled.", "No secret or token material is required by this workflow." ], summary: fatalBlocked - ? "Fatal preconditions blocked a full DEV artifact publish." - : "Fatal DEV-only artifact preconditions passed." + ? `Fatal preconditions blocked a full ${environment} artifact publish.` + : `Fatal ${environment} artifact preconditions passed.` }, blockers: devGateBlockers(blockers), artifactPublish: { - issue: "pikasTech/HWLAB#35", + issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35", status, mode, + lane: args.lane, + catalogPath: args.catalogPath, + imageTagMode: args.imageTagMode, repo, sourceCommitId: commitId, registryPrefix: args.registryPrefix, @@ -1945,7 +2051,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif quietBuild: args.quietBuild, concurrency: args.concurrency, baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight), - environment: ENVIRONMENT_DEV, + environment, buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null, serviceInventory, ciPlan: ciPlanReportSummary(ciPlan), @@ -2003,7 +2109,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif })), blockers }, - notes: "DEV-only artifact report. Do not treat runtime placeholder images as real service implementations." + notes: `${environment} artifact report. Do not treat runtime placeholder images as real service implementations.` }; } @@ -2041,19 +2147,29 @@ async function main() { if (baseImagePreflight.publishUsable) { args.baseImage = baseImagePreflight.localTag; } - const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([ - readJson(catalogPath), - readJson(deployPath), + const [deployManifest, commitId, remoteUrl] = await Promise.all([ + readJson(args.deployPath), gitValue(["rev-parse", "HEAD"]), gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git") ]); - const shortCommit = commitId.slice(0, 7); + let catalog = await readJsonIfPresent(args.catalogPath, null); + const catalogWasMissing = !catalog; + if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, commitId }); + assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`); + const shortCommit = imageTagForCommit(args, commitId); + let effectiveCatalogPath = args.catalogPath; + if (catalogWasMissing && args.lane === "v02") { + effectiveCatalogPath = path.join(os.tmpdir(), `hwlab-v02-artifact-catalog-${process.pid}.json`); + await writeFile(effectiveCatalogPath, `${JSON.stringify(catalog, null, 2)}\n`); + } const buildCreatedAt = new Date().toISOString(); const repo = repoLabelFromRemote(remoteUrl); const producer = g14ArtifactProducer(process.env); const ciPlan = await createG14CiPlan({ repoRoot, targetRef: "HEAD", + deployJsonPath: args.deployPath, + artifactCatalogPath: effectiveCatalogPath, registryPrefix: args.registryPrefix, baseImage: args.baseImage ?? undefined, ...(args.servicesExplicit ? { services: args.services } : {}) @@ -2122,7 +2238,7 @@ async function main() { blockers.push(blocker({ type: "environment_blocker", scope: service.serviceId, - summary: `${service.serviceId} cannot be reused because deploy/artifact-catalog.dev.json has no verified digest`, + summary: `${service.serviceId} cannot be reused because ${args.catalogPath} has no verified digest`, next: "Refresh the service catalog with a verified sha256 digest for this service, or create a service-scoped source change so the planner publishes it through the per-service BuildKit task." })); } diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 86ab9886..dc014521 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -14,10 +14,13 @@ const defaultRegistryPrefix = process.env.HWLAB_G14_REGISTRY_PREFIX || "127.0.0. const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikasTech/HWLAB.git"; const defaultBranch = process.env.HWLAB_G14_BRANCH || "G14"; const defaultGitopsBranch = process.env.HWLAB_G14_GITOPS_BRANCH || "G14-gitops"; +const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; const defaultRuntimeEndpoint = process.env.HWLAB_G14_RUNTIME_ENDPOINT || "http://74.48.78.17:17667"; const defaultWebEndpoint = process.env.HWLAB_G14_WEB_ENDPOINT || "http://74.48.78.17:17666"; const defaultProdRuntimeEndpoint = process.env.HWLAB_G14_PROD_RUNTIME_ENDPOINT || "http://74.48.78.17:18667"; const defaultProdWebEndpoint = process.env.HWLAB_G14_PROD_WEB_ENDPOINT || "http://74.48.78.17:18666"; +const defaultV02RuntimeEndpoint = process.env.HWLAB_V02_RUNTIME_ENDPOINT || "http://74.48.78.17:19667"; +const defaultV02WebEndpoint = process.env.HWLAB_V02_WEB_ENDPOINT || "http://74.48.78.17:19666"; const defaultProxyUrl = process.env.HWLAB_G14_PROXY_URL || "http://127.0.0.1:10808"; const defaultAllProxyUrl = process.env.HWLAB_G14_ALL_PROXY_URL || "socks5h://127.0.0.1:10808"; const defaultNoProxy = process.env.HWLAB_G14_NO_PROXY || "hyueapi.com,.hyueapi.com,127.0.0.1,localhost,::1,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,192.168.0.0/16,.svc,.cluster.local,kubernetes.default.svc,registry.hwlab-ci.svc,hwlab-registry.hwlab-ci.svc"; @@ -60,8 +63,8 @@ const primitiveValidationTasks = Object.freeze([ "node --check scripts/g14-gitops-render.mjs", "render_check_revision=$(git rev-parse HEAD)", "render_check_dir=$(mktemp -d)", - "node scripts/g14-gitops-render.mjs --out \"$render_check_dir\" --source-revision \"$render_check_revision\"", - "node scripts/g14-gitops-render.mjs --out \"$render_check_dir\" --check --source-revision \"$render_check_revision\"", + "node scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --source-revision \"$render_check_revision\"", + "node scripts/g14-gitops-render.mjs --lane \"$(params.lane)\" --catalog-path \"$(params.catalog-path)\" --image-tag-mode \"$(params.image-tag-mode)\" --source-branch \"$(params.source-branch)\" --gitops-branch \"$(params.gitops-branch)\" --out \"$render_check_dir\" --check --source-revision \"$render_check_revision\"", `node --input-type=module <<'NODE' import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; @@ -151,7 +154,10 @@ function proxyEnv() { function parseArgs(argv) { const args = { + lane: process.env.HWLAB_GITOPS_LANE || "g14", outDir: defaultOutDir, + catalogPath: defaultCatalogPath, + imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", registryPrefix: defaultRegistryPrefix, sourceRevision: null, sourceBranch: defaultBranch, @@ -168,7 +174,10 @@ function parseArgs(argv) { }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - if (arg === "--out") args.outDir = readOption(argv, ++index, arg); + if (arg === "--lane") args.lane = readOption(argv, ++index, arg); + else if (arg === "--out") args.outDir = readOption(argv, ++index, arg); + else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg); + else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg).replace(/\/+$/u, ""); else if (arg === "--source-revision") args.sourceRevision = readOption(argv, ++index, arg); else if (arg === "--source-branch") args.sourceBranch = readOption(argv, ++index, arg); @@ -186,6 +195,21 @@ function parseArgs(argv) { else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } + if (args.lane === "v02") { + if (args.sourceBranch === defaultBranch) args.sourceBranch = "v0.2"; + if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = "v0.2-gitops"; + if (args.catalogPath === defaultCatalogPath) args.catalogPath = "deploy/artifact-catalog.v02.json"; + if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultV02RuntimeEndpoint; + if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultV02WebEndpoint; + if (args.imageTagMode === "short") args.imageTagMode = "full"; + } + assert.ok(["g14", "v02"].includes(args.lane), `unknown lane ${args.lane}`); + assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); + if (args.lane === "v02") { + assert.equal(args.sourceBranch, "v0.2", "v02 source branch must be v0.2"); + assert.equal(args.gitopsBranch, "v0.2-gitops", "v02 GitOps branch must be v0.2-gitops"); + assert.equal(args.catalogPath, "deploy/artifact-catalog.v02.json", "v02 catalog path must be deploy/artifact-catalog.v02.json"); + } return args; } @@ -202,7 +226,10 @@ function usage() { "Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.json, and deploy/k8s/*.", "", "options:", + " --lane LANE g14 or v02; default: g14", ` --out DIR default: ${defaultOutDir}`, + ` --catalog-path PATH default: ${defaultCatalogPath}`, + " --image-tag-mode MODE short or full; v02 uses full source commit IDs", ` --source-repo URL default: ${defaultSourceRepo}`, ` --source-branch BRANCH default: ${defaultBranch}`, ` --gitops-branch BRANCH default: ${defaultGitopsBranch}`, @@ -226,6 +253,15 @@ async function readJson(relativePath) { return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8")); } +async function readJsonIfPresent(relativePath, fallback = null) { + try { + return await readJson(relativePath); + } catch (error) { + if (error && error.code === "ENOENT") return fallback; + throw error; + } +} + async function gitValue(args) { const result = await execFileAsync("git", args, { cwd: repoRoot, timeout: 10000, maxBuffer: 1024 * 1024 }); return result.stdout.trim(); @@ -315,8 +351,8 @@ function label(metadata, labels) { metadata.labels = { ...(metadata.labels ?? {}), ...labels }; } -function imageFor(registryPrefix, serviceId, shortCommit) { - return `${registryPrefix}/${serviceId}:${shortCommit}`; +function imageFor(registryPrefix, serviceId, imageTag) { + return `${registryPrefix}/${serviceId}:${imageTag}`; } function imageTagFromReference(image) { @@ -327,54 +363,159 @@ function imageTagFromReference(image) { return value.slice(colonIndex + 1).split("@", 1)[0] || null; } +function repositoryFromImage(image) { + const value = String(image ?? "").split("@", 1)[0]; + const slashIndex = value.lastIndexOf("/"); + const colonIndex = value.lastIndexOf(":"); + return colonIndex > slashIndex ? value.slice(0, colonIndex) : value; +} + +function digestPinnedImage(image, digest) { + if (!/^sha256:[a-f0-9]{64}$/u.test(String(digest ?? ""))) return image; + const repository = repositoryFromImage(image); + return repository ? `${repository}@${digest}` : image; +} + function catalogServiceById(catalog, serviceId) { return (catalog?.services ?? []).find((service) => service?.serviceId === serviceId) ?? null; } -function runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { +function runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false, digestPin = false }) { const catalogService = catalogServiceById(catalog, serviceId); + const fallbackImageTag = source.imageTag ?? source.short; const image = useDeployImages && catalogService?.image ? catalogService.image - : imageFor(registryPrefix, serviceId, source.short); + : imageFor(registryPrefix, serviceId, fallbackImageTag); + const runtimeImage = digestPin && useDeployImages ? digestPinnedImage(image, catalogService?.digest) : image; const imageTag = useDeployImages && catalogService?.imageTag ? catalogService.imageTag - : imageTagFromReference(image) ?? source.short; + : imageTagFromReference(image) ?? fallbackImageTag; const commit = useDeployImages ? catalogService?.sourceCommitId ?? catalogService?.commitId ?? imageTag ?? source.full : source.full; - return { image, imageTag, commit }; + return { image: runtimeImage, imageTag, commit }; } -function runtimeImageForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { - return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).image; +function runtimeImageForService(options) { + return runtimeArtifactForService(options).image; } -function runtimeCommitForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { - return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).commit; +function runtimeCommitForService(options) { + return runtimeArtifactForService(options).commit; } -function runtimeImageTagForService({ catalog, serviceId, source, registryPrefix, useDeployImages = false }) { - return runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }).imageTag; +function runtimeImageTagForService(options) { + return runtimeArtifactForService(options).imageTag; +} + +function artifactEnvironment(args) { + return args.lane === "v02" ? "v02" : "dev"; +} + +function artifactEndpoint(args) { + return args.lane === "v02" ? defaultV02RuntimeEndpoint : defaultRuntimeEndpoint; +} + +function artifactCatalogSkeleton({ args, source }) { + const environment = artifactEnvironment(args); + const namespace = args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"; + const tag = imageTagForSource(args, source); + return { + catalogVersion: "v1", + kind: "hwlab-artifact-catalog", + environment, + profile: environment, + namespace, + endpoint: artifactEndpoint(args), + commitId: tag, + artifactState: "contract-skeleton", + publish: { + registryPrefix: args.registryPrefix, + sourceCommitId: source.full, + imageTag: tag, + publishedAt: null + }, + allowedProfiles: [environment], + forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], + services: defaultServiceIds.map((serviceId) => ({ + serviceId, + profile: environment, + namespace, + commitId: tag, + sourceCommitId: source.full, + image: imageFor(args.registryPrefix, serviceId, tag), + imageTag: tag, + digest: null, + buildBackend: "contract-skeleton" + })) + }; } function namespaceNameForProfile(profile) { + if (profile === "v02") return "hwlab-v02"; return profile === "prod" ? "hwlab-prod" : "hwlab-dev"; } function runtimePathForProfile(profile) { + if (profile === "v02") return "runtime-v02"; return profile === "prod" ? "runtime-prod" : "runtime-dev"; } function runtimeLabelForProfile(profile) { + if (profile === "v02") return "v02"; return profile === "prod" ? "prod" : "dev"; } +function gitopsTargetForProfile(profile) { + return profile === "v02" ? "v02" : "g14"; +} + function profileEndpoint(args, profile) { + if (profile === "v02") return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; return profile === "prod" ? { runtimeEndpoint: args.prodRuntimeEndpoint, webEndpoint: args.prodWebEndpoint } : { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint }; } +function laneRuntimeProfiles(args) { + return args.lane === "v02" ? ["v02"] : ["dev", "prod"]; +} + +function laneNames(args) { + if (args.lane === "v02") { + return { + gitopsTarget: "v02", + pipeline: "hwlab-v02-ci-image-publish", + poller: "hwlab-v02-branch-poller", + reconciler: "hwlab-v02-control-plane-reconciler", + serviceAccount: "hwlab-v02-tekton-runner", + pipelineRunPrefix: "hwlab-v02-ci-poll-", + runtimeNamespace: "hwlab-v02", + runtimePath: "deploy/gitops/g14/runtime-v02", + argoApplications: ["argocd/hwlab-g14-v02"] + }; + } + return { + gitopsTarget: "g14", + pipeline: "hwlab-g14-ci-image-publish", + poller: "hwlab-g14-branch-poller", + reconciler: "hwlab-g14-control-plane-reconciler", + serviceAccount: "hwlab-tekton-runner", + pipelineRunPrefix: "hwlab-g14-ci-poll-", + runtimeNamespace: "hwlab-dev", + runtimePath: "deploy/gitops/g14/runtime-dev", + argoApplications: ["argocd/hwlab-g14-dev", "argocd/hwlab-g14-prod"] + }; +} + +function argoApplicationName(profile) { + return profile === "v02" ? "hwlab-g14-v02" : `hwlab-g14-${profile}`; +} + +function imageTagForSource(args, source) { + return args.imageTagMode === "full" ? source.full : source.short; +} + function deepSeekBaseUrl(namespace) { return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`; } @@ -404,7 +545,22 @@ function namespaceScopedHost(value, namespace) { return value.replace(/\.hwlab-dev\.svc\.cluster\.local/gu, `.${namespace}.svc.cluster.local`); } -function deployEnvEntry(name, value, namespace) { +function secretNameForProfile(secretName, profile) { + if (profile !== "v02") return secretName; + if (secretName === "hwlab-cloud-api-dev-db") return "hwlab-cloud-api-v02-db"; + if (secretName === "hwlab-code-agent-provider") return "hwlab-v02-code-agent-provider"; + if (secretName === "hwlab-code-agent-codex-auth") return "hwlab-v02-code-agent-codex-auth"; + return secretName; +} + +function rewritePodSecretRefs(podSpec, profile) { + if (!podSpec || profile !== "v02") return; + for (const volume of podSpec.volumes ?? []) { + if (volume?.secret?.secretName) volume.secret.secretName = secretNameForProfile(volume.secret.secretName, profile); + } +} + +function deployEnvEntry(name, value, namespace, profile = "dev") { if (typeof value === "string" && value.startsWith("secretRef:")) { const ref = value.slice("secretRef:".length); const slashIndex = ref.indexOf("/"); @@ -413,7 +569,7 @@ function deployEnvEntry(name, value, namespace) { name, valueFrom: { secretKeyRef: { - name: ref.slice(0, slashIndex), + name: secretNameForProfile(ref.slice(0, slashIndex), profile), key: ref.slice(slashIndex + 1), optional: true } @@ -424,18 +580,36 @@ function deployEnvEntry(name, value, namespace) { return { name, value: namespaceScopedHost(value, namespace) }; } -function applyDeployEnv(envList, deployService, namespace) { +function applyDeployEnv(envList, deployService, namespace, profile = "dev") { if (!deployService?.env || typeof deployService.env !== "object" || Array.isArray(deployService.env)) return; for (const [name, value] of Object.entries(deployService.env)) { - upsertEnvEntry(envList, deployEnvEntry(name, value, namespace)); + upsertEnvEntry(envList, deployEnvEntry(name, value, namespace, profile)); } } +function deployServicesForProfile(deploy, profile) { + const services = new Map((deploy.services ?? []).map((service) => [service.serviceId, cloneJson(service)])); + const laneServices = profile === "v02" && Array.isArray(deploy.lanes?.v02?.services) + ? deploy.lanes.v02.services + : []; + for (const override of laneServices) { + const existing = services.get(override.serviceId) ?? { serviceId: override.serviceId }; + services.set(override.serviceId, { + ...existing, + ...cloneJson(override), + env: { ...(existing.env ?? {}), ...(override.env ?? {}) } + }); + } + return services; +} + function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix, runtimeEndpoint, webEndpoint, profile = "dev", useDeployImages = false }) { const result = cloneJson(workloads); - const deployServices = new Map((deploy.services ?? []).map((service) => [service.serviceId, service])); + const deployServices = deployServicesForProfile(deploy, profile); const namespace = namespaceNameForProfile(profile); const profileLabel = runtimeLabelForProfile(profile); + const gitopsTarget = gitopsTargetForProfile(profile); + const digestPin = profile === "v02"; const stableRuntimeLabels = { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/environment": profileLabel, @@ -445,12 +619,12 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix item.metadata.namespace = namespace; label(item.metadata, { ...stableRuntimeLabels, - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": gitopsTarget, "hwlab.pikastech.local/source-commit": source.full, "unidesk.ai/g14-staging": profile === "dev" ? "true" : "false" }); annotate(item.metadata, { - "hwlab.pikastech.local/gitops-note": profile === "prod" ? "G14 PROD GitOps target." : "G14 DEV GitOps target.", + "hwlab.pikastech.local/gitops-note": profile === "v02" ? "HWLAB v0.2 GitOps target." : profile === "prod" ? "G14 PROD GitOps target." : "G14 DEV GitOps target.", "hwlab.pikastech.local/source-commit": source.full }); if (item.kind === "Job") { @@ -470,11 +644,11 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix const podTemplate = item?.spec?.template; if (!podTemplate?.spec?.containers) continue; const templateServiceId = serviceIdForWorkload(item, podTemplate.spec.containers[0]); - const templateArtifact = runtimeArtifactForService({ catalog, serviceId: templateServiceId, source, registryPrefix, useDeployImages }); + const templateArtifact = runtimeArtifactForService({ catalog, serviceId: templateServiceId, source, registryPrefix, useDeployImages, digestPin }); const templateSourceCommit = templateArtifact.commit; label(podTemplate.metadata ??= {}, { ...stableRuntimeLabels, - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": gitopsTarget, "hwlab.pikastech.local/source-commit": templateSourceCommit }); if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) { @@ -491,7 +665,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix const serviceId = serviceIdForWorkload(item, container); const deployService = deployServices.get(serviceId); if (!deployService) continue; - const artifact = runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages }); + const artifact = runtimeArtifactForService({ catalog, serviceId, source, registryPrefix, useDeployImages, digestPin }); const image = artifact.image; const runtimeCommit = artifact.commit; const runtimeImageTag = artifact.imageTag; @@ -501,7 +675,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix for (const entry of container.env) { if (Object.hasOwn(entry, "value")) entry.value = namespaceScopedHost(entry.value, namespace); } - applyDeployEnv(container.env, deployService, namespace); + applyDeployEnv(container.env, deployService, namespace, profile); upsertEnv(container.env, "HWLAB_ENVIRONMENT", profileLabel); upsertEnv(container.env, "HWLAB_COMMIT_ID", runtimeCommit); upsertEnv(container.env, "HWLAB_IMAGE", image); @@ -509,7 +683,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix if (serviceId === "hwlab-agent-skills") { upsertEnv(container.env, "HWLAB_SKILLS_COMMIT_ID", runtimeCommit); } - upsertEnv(container.env, "HWLAB_GITOPS_TARGET", "g14"); + upsertEnv(container.env, "HWLAB_GITOPS_TARGET", gitopsTarget); upsertEnv(container.env, "HWLAB_GITOPS_PROFILE", profileLabel); upsertEnv(container.env, "HWLAB_GITOPS_SOURCE_COMMIT", source.full); if (serviceId === "hwlab-cloud-api" || serviceId === "hwlab-edge-proxy") { @@ -541,6 +715,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix upsertEnv(container.env, "HWLAB_FRP_WEB_PUBLIC_PORT", "0"); } } + rewritePodSecretRefs(podTemplate.spec, profile); } return result; } @@ -754,13 +929,15 @@ function prepareSourceScript() { "git checkout \"$(params.revision)\"", "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", "ci_timing_emit source-clone succeeded \"$source_clone_started_ms\"", + "catalog_path=\"$(params.catalog-path)\"", + "mkdir -p \"$(dirname \"$catalog_path\")\"", "catalog_fetch_started_ms=\"$(ci_now_ms)\"", "if git ls-remote --exit-code --heads origin \"$(params.gitops-branch)\" >/dev/null 2>&1; then", " git fetch --depth 1 origin \"$(params.gitops-branch)\" >/dev/null 2>&1 || true", - " if git cat-file -e FETCH_HEAD:deploy/artifact-catalog.dev.json 2>/dev/null; then", - " git show FETCH_HEAD:deploy/artifact-catalog.dev.json > /tmp/hwlab-gitops-artifact-catalog.dev.json", - " if node -e 'const fs=require(\"fs\"); const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const source=JSON.parse(fs.readFileSync(process.argv[2],\"utf8\")); if (JSON.stringify(ids(gitops)) !== JSON.stringify(ids(source))) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.dev.json deploy/artifact-catalog.dev.json; then", - " cp /tmp/hwlab-gitops-artifact-catalog.dev.json deploy/artifact-catalog.dev.json", + " if git cat-file -e FETCH_HEAD:\"$catalog_path\" 2>/dev/null; then", + " git show FETCH_HEAD:\"$catalog_path\" > /tmp/hwlab-gitops-artifact-catalog.json", + " if node -e 'const fs=require(\"fs\"); const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const deploy=JSON.parse(fs.readFileSync(process.argv[2],\"utf8\")); if (JSON.stringify(ids(gitops)) !== JSON.stringify(ids(deploy))) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.json; then", + " cp /tmp/hwlab-gitops-artifact-catalog.json \"$catalog_path\"", " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"loaded\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'", " else", " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"ignored-stale\",\"reason\":\"service-ids-mismatch\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'", @@ -771,6 +948,20 @@ function prepareSourceScript() { "else", " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"gitops-branch-missing\"}'", "fi", + "if [ ! -s \"$catalog_path\" ] && [ \"$(params.lane)\" = \"v02\" ]; then", + " node - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" <<'NODE'", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const [catalogPath, revision, registryPrefix, imageTagMode] = process.argv.slice(2);", + "const deploy = JSON.parse(fs.readFileSync('deploy/deploy.json', 'utf8'));", + "const tag = imageTagMode === 'full' ? revision : revision.slice(0, 7);", + "const namespace = 'hwlab-v02';", + "const services = (deploy.services || []).map((service) => ({ serviceId: service.serviceId, profile: 'v02', namespace, commitId: tag, sourceCommitId: revision, image: `${registryPrefix}/${service.serviceId}:${tag}`, imageTag: tag, digest: null, buildBackend: 'contract-skeleton' }));", + "fs.mkdirSync(path.dirname(catalogPath), { recursive: true });", + "fs.writeFileSync(catalogPath, JSON.stringify({ catalogVersion: 'v1', kind: 'hwlab-artifact-catalog', environment: 'v02', profile: 'v02', namespace, endpoint: 'http://74.48.78.17:19667', commitId: tag, artifactState: 'contract-skeleton', publish: { registryPrefix, sourceCommitId: revision, imageTag: tag, publishedAt: null }, allowedProfiles: ['v02'], forbiddenProfiles: ['dev', 'prod'], services }, null, 2) + '\\n');", + "NODE", + " echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seeded-v02-skeleton\"}'", + "fi", "ci_timing_emit catalog-fetch succeeded \"$catalog_fetch_started_ms\"", "npm_ci_started_ms=\"$(ci_now_ms)\"", "npm ci --ignore-scripts", @@ -801,11 +992,25 @@ function primitiveValidationTask(task) { runAfter: ["prepare-source"], workspaces: [{ name: "source", workspace: "source" }], taskSpec: { - params: [{ name: "revision" }], + params: [ + { name: "revision" }, + { name: "lane" }, + { name: "catalog-path" }, + { name: "image-tag-mode" }, + { name: "source-branch" }, + { name: "gitops-branch" } + ], workspaces: [{ name: "source" }], steps: [{ name: task.name, image: ciToolsRunnerImage, env: proxyEnv(), script: sourceValidationScript(task.commands) }] }, - params: [{ name: "revision", value: "$(params.revision)" }] + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "lane", value: "$(params.lane)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, + { name: "image-tag-mode", value: "$(params.image-tag-mode)" }, + { name: "source-branch", value: "$(params.source-branch)" }, + { name: "gitops-branch", value: "$(params.gitops-branch)" } + ] }; } @@ -817,7 +1022,7 @@ echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"${ciToolsRunner for tool in node git; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"plan-artifacts","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done cd /workspace/source/repo test "$(git rev-parse HEAD)" = "$(params.revision)" -node scripts/g14-ci-plan.mjs --target-ref HEAD --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json +node scripts/g14-ci-plan.mjs --target-ref HEAD --deploy-json deploy/deploy.json --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json node - <<'NODE' const fs = require("node:fs"); const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8")); @@ -856,11 +1061,15 @@ for tool in node npm git python3 ssh curl; do command -v "$tool" >/dev/null 2>&1 git config --global --add safe.directory /workspace/source/repo cd /workspace/source/repo test "$(git rev-parse HEAD)" = "$(params.revision)" +export HWLAB_ARTIFACT_LANE="$(params.lane)" +export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" +export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json" +export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" if [ -s /workspace/source/affected-services.json ] && ! node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync("/workspace/source/affected-services.json","utf8")); const service=process.argv[1]; const item=(p.entries||[]).find((entry)=>entry.serviceId===service); process.exit(item && item.affected ? 0 : 1);' "$(params.service-id)"; then node - "$(params.service-id)" <<'NODE' const fs = require("node:fs"); const serviceId = process.argv[2]; -const catalog = JSON.parse(fs.readFileSync("deploy/artifact-catalog.dev.json", "utf8")); +const catalog = JSON.parse(fs.readFileSync(process.env.HWLAB_ARTIFACT_CATALOG_PATH, "utf8")); const service = (catalog.services || []).find((item) => item.serviceId === serviceId) || {}; const image = service.image || ""; const digest = service.digest || "not_published"; @@ -919,7 +1128,7 @@ if [ "$buildkit_ready" != "1" ]; then echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2 exit 1 fi -node scripts/g14-artifact-publish.mjs --publish --registry-prefix "$(params.registry-prefix)" --no-report --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR" +node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --no-report --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR" `; } @@ -936,8 +1145,12 @@ export HWLAB_CI_ARTIFACT_RUN_OWNER="tekton" export HWLAB_DEV_REGISTRY_PREFIX="$(params.registry-prefix)" export HWLAB_DEV_REGISTRY_K8S_NATIVE=1 if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi -node scripts/g14-artifact-publish.mjs --publish --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-results-env -node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write +export HWLAB_ARTIFACT_LANE="$(params.lane)" +export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" +export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json" +export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" +node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-results-env +node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write `; } @@ -977,12 +1190,15 @@ check_source_head() { check_source_head before-render git config --global user.name "HWLAB G14 GitOps Bot" git config --global user.email "hwlab-g14-gitops-bot@users.noreply.github.com" +catalog_path="$(params.catalog-path)" +runtime_path="$(params.runtime-path)" +lane="$(params.lane)" if [ -s /workspace/source/dev-artifacts.json ]; then - node scripts/refresh-artifact-catalog.mjs --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write + node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write fi gitops_render_started_ms="$(ci_now_ms)" -node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images -node scripts/g14-gitops-render.mjs --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check +node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images +node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images --check ci_timing_emit gitops-render succeeded "$gitops_render_started_ms" check_source_head before-push workdir="$(mktemp -d)" @@ -992,16 +1208,23 @@ cd "$workdir/gitops" if git ls-remote --exit-code --heads origin "$(params.gitops-branch)" >/dev/null 2>&1; then git fetch origin "$(params.gitops-branch)" git checkout -B "$(params.gitops-branch)" "origin/$(params.gitops-branch)" - rm -rf deploy/gitops/g14 + if [ "$lane" = "v02" ]; then rm -rf "$runtime_path" "$catalog_path"; else rm -rf deploy/gitops/g14 "$catalog_path"; fi else git checkout --orphan "$(params.gitops-branch)" git rm -rf . >/dev/null 2>&1 || true fi ci_timing_emit gitops-clone succeeded "$gitops_clone_started_ms" -mkdir -p deploy/gitops -cp -a /workspace/source/repo/deploy/gitops/g14 deploy/gitops/g14 -cp /workspace/source/repo/deploy/artifact-catalog.dev.json deploy/artifact-catalog.dev.json -git add deploy/artifact-catalog.dev.json deploy/gitops/g14 +mkdir -p "$(dirname "$runtime_path")" "$(dirname "$catalog_path")" +if [ "$lane" = "v02" ]; then + cp -a "/workspace/source/repo/$runtime_path" "$runtime_path" + cp "/workspace/source/repo/$catalog_path" "$catalog_path" + git add "$catalog_path" "$runtime_path" +else + mkdir -p deploy/gitops + cp -a /workspace/source/repo/deploy/gitops/g14 deploy/gitops/g14 + cp "/workspace/source/repo/$catalog_path" "$catalog_path" + git add "$catalog_path" deploy/gitops/g14 +fi if git diff --cached --quiet; then echo '{"status":"unchanged","gitopsBranch":"'"$(params.gitops-branch)"'","sourceRevision":"'"$(params.revision)"'"}' ci_timing_emit gitops-commit unchanged "$(ci_now_ms)" @@ -1034,8 +1257,11 @@ workdir="$(mktemp -d)" git clone --depth 1 --branch "$SOURCE_BRANCH" "$GIT_URL" "$workdir/repo" cd "$workdir/repo" revision="$(git rev-parse HEAD)" -node scripts/g14-gitops-render.mjs --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" -node scripts/g14-gitops-render.mjs --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" --check +LANE="\${LANE:-g14}" +CATALOG_PATH="\${CATALOG_PATH:-deploy/artifact-catalog.dev.json}" +IMAGE_TAG_MODE="\${IMAGE_TAG_MODE:-short}" +node scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" +node scripts/g14-gitops-render.mjs --lane "$LANE" --catalog-path "$CATALOG_PATH" --image-tag-mode "$IMAGE_TAG_MODE" --source-revision "$revision" --source-repo "$GIT_URL" --source-branch "$SOURCE_BRANCH" --gitops-branch "$GITOPS_BRANCH" --registry-prefix "$REGISTRY_PREFIX" --check node <<'NODE' const fs = require("node:fs"); const https = require("node:https"); @@ -1047,18 +1273,25 @@ if (!host) throw new Error("KUBERNETES_SERVICE_HOST is not available"); const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"); const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); +const tektonDir = process.env.TEKTON_DIR || "tekton"; +const argoFiles = String(process.env.ARGO_FILES || "").split(",").map((item) => item.trim()).filter(Boolean); +const fieldManager = process.env.FIELD_MANAGER || "hwlab-g14-control-plane-reconciler"; +const eventName = process.env.RECONCILE_EVENT || "g14-control-plane-reconciled"; const manifestFiles = [ - "deploy/gitops/g14/tekton/rbac.yaml", - "deploy/gitops/g14/tekton/pipeline.yaml", - "deploy/gitops/g14/tekton/poller.yaml", - "deploy/gitops/g14/tekton/control-plane-reconciler.yaml" + "deploy/gitops/g14/" + tektonDir + "/rbac.yaml", + "deploy/gitops/g14/" + tektonDir + "/pipeline.yaml", + "deploy/gitops/g14/" + tektonDir + "/poller.yaml", + "deploy/gitops/g14/" + tektonDir + "/control-plane-reconciler.yaml", + ...argoFiles ]; const plurals = new Map([ ["ServiceAccount", "serviceaccounts"], ["Role", "roles"], ["RoleBinding", "rolebindings"], ["Pipeline", "pipelines"], - ["CronJob", "cronjobs"] + ["CronJob", "cronjobs"], + ["Application", "applications"], + ["AppProject", "appprojects"] ]); function encode(value) { @@ -1112,14 +1345,14 @@ function request(method, path, body) { results.push({ file, kind: item.kind, name: item.metadata?.name || null, status: "skipped-cluster-scoped" }); continue; } - const response = await request("PATCH", path + "?fieldManager=hwlab-g14-control-plane-reconciler&force=true", item); + const response = await request("PATCH", path + "?fieldManager=" + encode(fieldManager) + "&force=true", item); if (response.statusCode < 200 || response.statusCode > 299) { throw new Error("apply failed for " + item.kind + "/" + item.metadata.name + " status=" + response.statusCode + " body=" + response.body.slice(0, 1000)); } results.push({ file, kind: item.kind, name: item.metadata.name, status: "applied", statusCode: response.statusCode }); } } - console.log(JSON.stringify({ event: "g14-control-plane-reconciled", sourceRevision: process.env.RECONCILE_REVISION || null, results }, null, 2)); + console.log(JSON.stringify({ event: eventName, sourceRevision: process.env.RECONCILE_REVISION || null, results }, null, 2)); })().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; @@ -1151,11 +1384,12 @@ case "$subject" in ;; esac short="$(printf '%.12s' "$revision")" -name="hwlab-g14-ci-poll-$short" +prefix="\${PIPELINERUN_PREFIX:-hwlab-g14-ci-poll}" +name="$prefix-$short" export POLLER_REVISION="$revision" export POLLER_PIPELINERUN="$name" export POLLER_SOURCE_SUBJECT="$subject" -echo "Checking G14 source $revision with PipelineRun $name" +echo "Checking \${GITOPS_TARGET:-g14} source $revision with PipelineRun $name" node <<'NODE' const fs = require("node:fs"); const https = require("node:https"); @@ -1197,7 +1431,7 @@ function request(method, path, body) { const labels = { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": env("GITOPS_TARGET", "g14"), "hwlab.pikastech.local/source-commit": revision, "hwlab.pikastech.local/trigger": "polling" }; @@ -1218,13 +1452,17 @@ const pipelineRun = { spec: { pipelineRef: { name: env("PIPELINE_NAME", "hwlab-g14-ci-image-publish") }, taskRunTemplate: { - serviceAccountName: "hwlab-tekton-runner", + serviceAccountName: env("SERVICE_ACCOUNT_NAME", "hwlab-tekton-runner"), podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", securityContext: { fsGroup: 1000 } } }, params: [ { name: "git-url", value: env("GIT_URL", "git@github.com:pikasTech/HWLAB.git") }, { name: "source-branch", value: env("SOURCE_BRANCH", "G14") }, { name: "gitops-branch", value: env("GITOPS_BRANCH", "G14-gitops") }, + { name: "lane", value: env("LANE", "g14") }, + { name: "catalog-path", value: env("CATALOG_PATH", "deploy/artifact-catalog.dev.json") }, + { name: "image-tag-mode", value: env("IMAGE_TAG_MODE", "short") }, + { name: "runtime-path", value: env("RUNTIME_PATH", "deploy/gitops/g14/runtime-dev") }, { name: "revision", value: revision }, { name: "registry-prefix", value: env("REGISTRY_PREFIX", "127.0.0.1:5000/hwlab") }, { name: "services", value: env("SERVICES", "${defaultServicesParam}") }, @@ -1262,7 +1500,104 @@ NODE `; } -function tektonRbac() { +function ciLaneSettings(argsOrLane = "g14") { + const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane; + if (lane === "v02") { + return { + lane: "v02", + profile: "v02", + gitopsTarget: "v02", + pipelineName: "hwlab-v02-ci-image-publish", + pipelineRunPrefix: "hwlab-v02-ci-poll", + serviceAccountName: "hwlab-v02-tekton-runner", + pollerName: "hwlab-v02-branch-poller", + controlPlaneReconcilerName: "hwlab-v02-control-plane-reconciler", + tektonDir: "tekton-v02", + catalogPath: "deploy/artifact-catalog.v02.json", + imageTagMode: "full", + runtimeNamespace: "hwlab-v02" + }; + } + return { + lane: "g14", + profile: "dev", + gitopsTarget: "g14", + pipelineName: "hwlab-g14-ci-image-publish", + pipelineRunPrefix: "hwlab-g14-ci-poll", + serviceAccountName: "hwlab-tekton-runner", + pollerName: "hwlab-g14-branch-poller", + controlPlaneReconcilerName: "hwlab-g14-control-plane-reconciler", + tektonDir: "tekton", + catalogPath: "deploy/artifact-catalog.dev.json", + imageTagMode: "short", + runtimeNamespace: "hwlab-dev" + }; +} + +function tektonRbac(args = { lane: "g14" }) { + const settings = ciLaneSettings(args); + if (settings.lane === "v02") { + return { + apiVersion: "v1", + kind: "List", + items: [ + { apiVersion: "v1", kind: "Namespace", metadata: { name: "hwlab-ci" } }, + { apiVersion: "v1", kind: "ServiceAccount", metadata: { name: settings.serviceAccountName, namespace: "hwlab-ci" } }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" }, + rules: [ + { apiGroups: [""], resources: ["pods", "services", "configmaps"], verbs: ["get", "list", "watch"] }, + { apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] }, + { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-runtime-observer" } + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" }, + rules: [ + { apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] }, + { apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["batch"], resources: ["cronjobs"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] }, + { apiGroups: [""], resources: ["serviceaccounts"], verbs: ["get", "patch", "create"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-pipelinerun-writer" } + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" }, + rules: [ + { apiGroups: ["argoproj.io"], resources: ["applications", "appprojects"], verbs: ["get", "patch", "create"] }, + { apiGroups: ["rbac.authorization.k8s.io"], resources: ["roles", "rolebindings"], verbs: ["get", "patch", "create"] } + ] + }, + { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" }, + subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }], + roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-argocd-reconciler" } + } + ] + }; + } return { apiVersion: "v1", kind: "List", @@ -1424,6 +1759,7 @@ function planArtifactsTask() { taskSpec: { params: [ { name: "revision" }, + { name: "catalog-path" }, { name: "registry-prefix" }, { name: "services" } ], @@ -1438,6 +1774,7 @@ function planArtifactsTask() { }, params: [ { name: "revision", value: "$(params.revision)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "services", value: "$(params.services)" } ] @@ -1452,6 +1789,9 @@ function perServiceBuildTask(serviceId) { taskSpec: { params: [ { name: "revision" }, + { name: "lane" }, + { name: "catalog-path" }, + { name: "image-tag-mode" }, { name: "registry-prefix" }, { name: "service-id" }, { name: "base-image" } @@ -1474,6 +1814,9 @@ function perServiceBuildTask(serviceId) { }, params: [ { name: "revision", value: "$(params.revision)" }, + { name: "lane", value: "$(params.lane)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, + { name: "image-tag-mode", value: "$(params.image-tag-mode)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "service-id", value: serviceId }, { name: "base-image", value: "$(params.base-image)" } @@ -1489,6 +1832,9 @@ function collectArtifactsTask() { taskSpec: { params: [ { name: "revision" }, + { name: "lane" }, + { name: "catalog-path" }, + { name: "image-tag-mode" }, { name: "registry-prefix" }, { name: "services" }, { name: "base-image" }, @@ -1499,6 +1845,9 @@ function collectArtifactsTask() { }, params: [ { name: "revision", value: "$(params.revision)" }, + { name: "lane", value: "$(params.lane)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, + { name: "image-tag-mode", value: "$(params.image-tag-mode)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "services", value: "$(params.services)" }, { name: "base-image", value: "$(params.base-image)" }, @@ -1522,6 +1871,8 @@ const https = require("node:https"); const host = process.env.KUBERNETES_SERVICE_HOST; const port = process.env.KUBERNETES_SERVICE_PORT || "443"; const revision = process.env.HWLAB_SOURCE_REVISION || ""; +const runtimeNamespace = process.env.HWLAB_RUNTIME_NAMESPACE || "hwlab-dev"; +const gitopsTarget = process.env.HWLAB_GITOPS_TARGET || "g14"; const pipelineRun = process.env.HWLAB_TEKTON_PIPELINERUN || null; const taskRun = process.env.HWLAB_TEKTON_TASKRUN || null; const task = process.env.HWLAB_TEKTON_TASK || null; @@ -1590,12 +1941,12 @@ function listPath(group, version, namespace, resource) { async function runtimeItems() { const [deployments, statefulsets] = await Promise.all([ - request("GET", listPath("apps", "v1", "hwlab-dev", "deployments")), - request("GET", listPath("apps", "v1", "hwlab-dev", "statefulsets")) + request("GET", listPath("apps", "v1", runtimeNamespace, "deployments")), + request("GET", listPath("apps", "v1", runtimeNamespace, "statefulsets")) ]); if (deployments.statusCode === 403 || statefulsets.statusCode === 403) return { status: "rbac-denied", items: [] }; const items = [...(deployments.json?.items || []), ...(statefulsets.json?.items || [])] - .filter((item) => item.metadata?.labels?.["hwlab.pikastech.local/gitops-target"] === "g14"); + .filter((item) => item.metadata?.labels?.["hwlab.pikastech.local/gitops-target"] === gitopsTarget); return { status: "ok", items }; } @@ -1669,15 +2020,28 @@ NODE `; } -function runtimeReadyTask() { +function runtimeReadyTask({ profile = "dev" } = {}) { return { name: "runtime-ready", runAfter: ["gitops-promote"], taskSpec: { - params: [{ name: "revision" }], - steps: [{ name: "runtime-ready", image: ciToolsRunnerImage, env: proxyEnv(), script: runtimeReadyScript() }] + params: [{ name: "revision" }, { name: "runtime-namespace" }, { name: "gitops-target" }], + steps: [{ + name: "runtime-ready", + image: ciToolsRunnerImage, + env: [ + ...proxyEnv(), + { name: "HWLAB_RUNTIME_NAMESPACE", value: "$(params.runtime-namespace)" }, + { name: "HWLAB_GITOPS_TARGET", value: "$(params.gitops-target)" } + ], + script: runtimeReadyScript() + }] }, - params: [{ name: "revision", value: "$(params.revision)" }] + params: [ + { name: "revision", value: "$(params.revision)" }, + { name: "runtime-namespace", value: namespaceNameForProfile(profile) }, + { name: "gitops-target", value: gitopsTargetForProfile(profile) } + ] }; } @@ -1689,16 +2053,18 @@ function imagePublishTaskSet() { ]; } -function tektonPipeline() { +function tektonPipeline(args = { lane: "g14" }) { + const settings = ciLaneSettings(args); + const runtimePath = `deploy/gitops/g14/${runtimePathForProfile(settings.profile)}`; return { apiVersion: "tekton.dev/v1", kind: "Pipeline", metadata: { - name: "hwlab-g14-ci-image-publish", + name: settings.pipelineName, namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14" + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget }, annotations: { "hwlab.pikastech.local/source-config": "scripts/g14-gitops-render.mjs#tekton-native-primitive-ci", @@ -1709,8 +2075,12 @@ function tektonPipeline() { spec: { params: [ { name: "git-url", type: "string", default: defaultSourceRepo }, - { name: "source-branch", type: "string", default: defaultBranch }, - { name: "gitops-branch", type: "string", default: defaultGitopsBranch }, + { name: "source-branch", type: "string", default: args.sourceBranch }, + { name: "gitops-branch", type: "string", default: args.gitopsBranch }, + { name: "lane", type: "string", default: settings.lane }, + { name: "catalog-path", type: "string", default: args.catalogPath || settings.catalogPath }, + { name: "image-tag-mode", type: "string", default: args.imageTagMode || settings.imageTagMode }, + { name: "runtime-path", type: "string", default: runtimePath }, { name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." }, { name: "registry-prefix", type: "string", default: defaultRegistryPrefix }, { name: "services", type: "string", default: defaultServicesParam }, @@ -1732,6 +2102,10 @@ function tektonPipeline() { { name: "git-url" }, { name: "source-branch" }, { name: "gitops-branch" }, + { name: "lane" }, + { name: "catalog-path" }, + { name: "image-tag-mode" }, + { name: "registry-prefix" }, { name: "revision" } ], workspaces: [{ name: "source" }, { name: "git-ssh" }], @@ -1741,6 +2115,10 @@ function tektonPipeline() { { name: "git-url", value: "$(params.git-url)" }, { name: "source-branch", value: "$(params.source-branch)" }, { name: "gitops-branch", value: "$(params.gitops-branch)" }, + { name: "lane", value: "$(params.lane)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, + { name: "image-tag-mode", value: "$(params.image-tag-mode)" }, + { name: "registry-prefix", value: "$(params.registry-prefix)" }, { name: "revision", value: "$(params.revision)" } ] }, @@ -1758,6 +2136,10 @@ function tektonPipeline() { { name: "git-url" }, { name: "source-branch" }, { name: "gitops-branch" }, + { name: "lane" }, + { name: "catalog-path" }, + { name: "image-tag-mode" }, + { name: "runtime-path" }, { name: "revision" }, { name: "registry-prefix" } ], @@ -1768,33 +2150,39 @@ function tektonPipeline() { { name: "git-url", value: "$(params.git-url)" }, { name: "source-branch", value: "$(params.source-branch)" }, { name: "gitops-branch", value: "$(params.gitops-branch)" }, + { name: "lane", value: "$(params.lane)" }, + { name: "catalog-path", value: "$(params.catalog-path)" }, + { name: "image-tag-mode", value: "$(params.image-tag-mode)" }, + { name: "runtime-path", value: "$(params.runtime-path)" }, { name: "revision", value: "$(params.revision)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" } ] }, - runtimeReadyTask() + runtimeReadyTask({ profile: settings.profile }) ] } }; } function tektonPipelineRunTemplate({ source, args }) { + const settings = ciLaneSettings(args); + const runtimePath = `deploy/gitops/g14/${runtimePathForProfile(settings.profile)}`; return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", metadata: { - generateName: "hwlab-g14-ci-", + generateName: `${settings.pipelineRunPrefix}-manual-`, namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, "hwlab.pikastech.local/source-commit": source.full } }, spec: { - pipelineRef: { name: "hwlab-g14-ci-image-publish" }, + pipelineRef: { name: settings.pipelineName }, taskRunTemplate: { - serviceAccountName: "hwlab-tekton-runner", + serviceAccountName: settings.serviceAccountName, podTemplate: { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", @@ -1805,6 +2193,10 @@ function tektonPipelineRunTemplate({ source, args }) { { name: "git-url", value: args.sourceRepo }, { name: "source-branch", value: args.sourceBranch }, { name: "gitops-branch", value: args.gitopsBranch }, + { name: "lane", value: settings.lane }, + { name: "catalog-path", value: args.catalogPath || settings.catalogPath }, + { name: "image-tag-mode", value: args.imageTagMode || settings.imageTagMode }, + { name: "runtime-path", value: runtimePath }, { name: "revision", value: source.full }, { name: "registry-prefix", value: args.registryPrefix }, { name: "base-image", value: defaultDevBaseImage } @@ -1818,15 +2210,16 @@ function tektonPipelineRunTemplate({ source, args }) { } function tektonPollerCronJob(args) { + const settings = ciLaneSettings(args); return { apiVersion: "batch/v1", kind: "CronJob", metadata: { - name: "hwlab-g14-branch-poller", + name: settings.pollerName, namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14" + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget }, annotations: { "hwlab.pikastech.local/source-branch": args.sourceBranch, @@ -1845,11 +2238,11 @@ function tektonPollerCronJob(args) { metadata: { labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14" + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget } }, spec: { - serviceAccountName: "hwlab-tekton-runner", + serviceAccountName: settings.serviceAccountName, restartPolicy: "Never", hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", @@ -1860,7 +2253,14 @@ function tektonPollerCronJob(args) { env: [ ...proxyEnv(), { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, - { name: "PIPELINE_NAME", value: "hwlab-g14-ci-image-publish" }, + { name: "PIPELINE_NAME", value: settings.pipelineName }, + { name: "PIPELINERUN_PREFIX", value: settings.pipelineRunPrefix }, + { name: "SERVICE_ACCOUNT_NAME", value: settings.serviceAccountName }, + { name: "GITOPS_TARGET", value: settings.gitopsTarget }, + { name: "LANE", value: settings.lane }, + { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, + { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, + { name: "RUNTIME_PATH", value: `deploy/gitops/g14/${runtimePathForProfile(settings.profile)}` }, { name: "GIT_URL", value: args.sourceRepo }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, { name: "GITOPS_BRANCH", value: args.gitopsBranch }, @@ -1882,15 +2282,16 @@ function tektonPollerCronJob(args) { } function tektonControlPlaneReconcilerCronJob(args) { + const settings = ciLaneSettings(args); return { apiVersion: "batch/v1", kind: "CronJob", metadata: { - name: "hwlab-g14-control-plane-reconciler", + name: settings.controlPlaneReconcilerName, namespace: "hwlab-ci", labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14" + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget }, annotations: { "hwlab.pikastech.local/source-branch": args.sourceBranch, @@ -1910,11 +2311,11 @@ function tektonControlPlaneReconcilerCronJob(args) { metadata: { labels: { "app.kubernetes.io/part-of": "hwlab", - "hwlab.pikastech.local/gitops-target": "g14" + "hwlab.pikastech.local/gitops-target": settings.gitopsTarget } }, spec: { - serviceAccountName: "hwlab-tekton-runner", + serviceAccountName: settings.serviceAccountName, restartPolicy: "Never", hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", @@ -1925,6 +2326,13 @@ function tektonControlPlaneReconcilerCronJob(args) { env: [ ...proxyEnv(), { name: "POD_NAMESPACE", valueFrom: { fieldRef: { fieldPath: "metadata.namespace" } } }, + { name: "LANE", value: settings.lane }, + { name: "CATALOG_PATH", value: args.catalogPath || settings.catalogPath }, + { name: "IMAGE_TAG_MODE", value: args.imageTagMode || settings.imageTagMode }, + { name: "TEKTON_DIR", value: settings.tektonDir }, + { name: "ARGO_FILES", value: settings.lane === "v02" ? "deploy/gitops/g14/argocd/project.yaml,deploy/gitops/g14/argocd/application-v02.yaml" : "" }, + { name: "FIELD_MANAGER", value: settings.controlPlaneReconcilerName }, + { name: "RECONCILE_EVENT", value: `${settings.gitopsTarget}-control-plane-reconciled` }, { name: "GIT_URL", value: args.sourceRepo }, { name: "SOURCE_BRANCH", value: args.sourceBranch }, { name: "GITOPS_BRANCH", value: args.gitopsBranch }, @@ -1986,7 +2394,21 @@ function registryManifest() { }; } -function argoProject() { +function argoProject(args = { lane: "g14" }) { + if (args.lane === "v02") { + return { + apiVersion: "argoproj.io/v1alpha1", + kind: "AppProject", + metadata: { name: "hwlab-v02", namespace: "argocd" }, + spec: { + description: "HWLAB v0.2 additive GitOps project on G14; DEV/PROD remain outside this lane.", + sourceRepos: [defaultSourceRepo], + destinations: [{ server: "https://kubernetes.default.svc", namespace: "hwlab-v02" }], + clusterResourceWhitelist: [{ group: "", kind: "Namespace" }], + namespaceResourceWhitelist: [{ group: "*", kind: "*" }] + } + }; + } return { apiVersion: "argoproj.io/v1alpha1", kind: "AppProject", @@ -2007,16 +2429,18 @@ function argoProject() { function argoApplication(args, profile = "dev") { const namespace = namespaceNameForProfile(profile); const runtimePath = runtimePathForProfile(profile); + const gitopsTarget = gitopsTargetForProfile(profile); + const project = profile === "v02" ? "hwlab-v02" : "hwlab-g14"; return { apiVersion: "argoproj.io/v1alpha1", kind: "Application", metadata: { - name: `hwlab-g14-${profile}`, + name: argoApplicationName(profile), namespace: "argocd", - labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": "g14" } + labels: { "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/gitops-target": gitopsTarget } }, spec: { - project: "hwlab-g14", + project, source: { repoURL: args.sourceRepo, targetRevision: args.gitopsBranch, path: `deploy/gitops/g14/${runtimePath}` }, destination: { server: "https://kubernetes.default.svc", namespace }, syncPolicy: { automated: { prune: false, selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] } @@ -2027,16 +2451,16 @@ function argoApplication(args, profile = "dev") { function g14FrpcManifest({ profile = "dev" } = {}) { const namespace = namespaceNameForProfile(profile); const profileLabel = runtimeLabelForProfile(profile); - const deploymentName = profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"; + const deploymentName = profile === "v02" ? "hwlab-v02-frpc" : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"; const configName = `${deploymentName}-config`; - const proxyPrefix = profile === "prod" ? "hwlab-g14-prod" : "hwlab-g14"; - const webRemotePort = profile === "prod" ? 18666 : 17666; - const edgeRemotePort = profile === "prod" ? 18667 : 17667; + const proxyPrefix = profile === "v02" ? "hwlab-v02" : profile === "prod" ? "hwlab-g14-prod" : "hwlab-g14"; + const webRemotePort = profile === "v02" ? 19666 : profile === "prod" ? 18666 : 17666; + const edgeRemotePort = profile === "v02" ? 19667 : profile === "prod" ? 18667 : 17667; const labels = { "app.kubernetes.io/name": deploymentName, "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/environment": profileLabel, - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), "hwlab.pikastech.local/profile": profileLabel }; return { @@ -2105,14 +2529,15 @@ remotePort = ${edgeRemotePort} function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defaultRegistryPrefix, catalog = null, useDeployImages = false } = {}) { const namespace = namespaceNameForProfile(profile); const bridgeServiceId = "hwlab-cloud-api"; - const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); - const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages }); + const digestPin = profile === "v02"; + const bridgeImage = runtimeImageForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin }); + const bridgeCommit = runtimeCommitForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin }); + const bridgeImageTag = runtimeImageTagForService({ catalog, serviceId: bridgeServiceId, source, registryPrefix, useDeployImages, digestPin }); const labels = { "app.kubernetes.io/name": "hwlab-deepseek-proxy", "app.kubernetes.io/part-of": "hwlab", "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), - "hwlab.pikastech.local/gitops-target": "g14", + "hwlab.pikastech.local/gitops-target": gitopsTargetForProfile(profile), "hwlab.pikastech.local/service-id": "hwlab-deepseek-proxy" }; const templateAnnotations = { @@ -2148,7 +2573,7 @@ function deepSeekProxyManifest({ profile = "dev", source, registryPrefix = defau image: bridgeImage, imagePullPolicy: "IfNotPresent", command: ["/bin/sh", "-eu", "/scripts/render-config.sh"], - env: [{ name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: "hwlab-code-agent-provider", key: "openai-api-key", optional: true } } }], + env: [{ name: "DEEPSEEK_API_KEY", valueFrom: { secretKeyRef: { name: secretNameForProfile("hwlab-code-agent-provider", profile), key: "openai-api-key", optional: true } } }], volumeMounts: [ { name: "moonbridge-scripts", mountPath: "/scripts", readOnly: true }, { name: "moonbridge-config", mountPath: "/config" } @@ -2484,8 +2909,76 @@ function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, ca }; } +function v02PostgresManifest({ migrationSql }) { + const namespace = "hwlab-v02"; + const name = "hwlab-v02-postgres"; + const labels = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "hwlab", + "hwlab.pikastech.local/environment": "v02", + "hwlab.pikastech.local/gitops-target": "v02", + "hwlab.pikastech.local/profile": "v02" + }; + return { + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: `${name}-init`, namespace, labels }, + data: { "0001_cloud_core_skeleton.sql": migrationSql } + }, + { + apiVersion: "v1", + kind: "Service", + metadata: { name, namespace, labels }, + spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": name }, ports: [{ name: "postgres", port: 5432, targetPort: "postgres" }] } + }, + { + apiVersion: "apps/v1", + kind: "StatefulSet", + metadata: { name, namespace, labels }, + spec: { + serviceName: name, + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": name } }, + template: { + metadata: { labels }, + spec: { + containers: [{ + name: "postgres", + image: "127.0.0.1:5000/hwlab/postgres:16-alpine", + imagePullPolicy: "IfNotPresent", + env: [ + { name: "POSTGRES_DB", value: "hwlab_v02" }, + { name: "POSTGRES_USER", value: "hwlab_v02" }, + { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: "hwlab-v02-postgres", key: "POSTGRES_PASSWORD" } } } + ], + ports: [{ name: "postgres", containerPort: 5432 }], + readinessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 5, periodSeconds: 10 }, + livenessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 30, periodSeconds: 20 }, + volumeMounts: [ + { name: "data", mountPath: "/var/lib/postgresql/data" }, + { name: "init", mountPath: "/docker-entrypoint-initdb.d", readOnly: true } + ] + }], + volumes: [{ name: "init", configMap: { name: `${name}-init` } }] + } + }, + volumeClaimTemplates: [{ + metadata: { name: "data" }, + spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "8Gi" } } } + }] + } + } + ] + }; +} + function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev" } = {}) { const resources = ["namespace.yaml", "code-agent-codex-config.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"]; + if (profile === "v02") resources.push("postgres.yaml"); if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml"); resources.push("g14-frpc.yaml"); return { @@ -2497,6 +2990,21 @@ function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile == } function readme({ source, args }) { + if (args.lane === "v02") { + return `# HWLAB v0.2 GitOps CI/CD + +This directory is generated by \`scripts/g14-gitops-render.mjs --lane v02\` from source branch \`${args.sourceBranch}\`. + +- Target: additive G14 v0.2 lane only. +- CI: Tekton Pipeline \`hwlab-ci/hwlab-v02-ci-image-publish\`. +- Artifact contract: runtime images come from \`${args.catalogPath}\`; source branch \`v0.2\` does not track this generated catalog. +- Branch split: source branch \`${args.sourceBranch}\` remains human-authored source; generated desired state is promoted to \`${args.gitopsBranch}\`. +- CD: Argo CD Application \`argocd/hwlab-g14-v02\` consumes \`${args.gitopsBranch}:deploy/gitops/g14/runtime-v02\` and deploys only to namespace \`hwlab-v02\`. +- Public preview: FRP exposes v0.2 web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-v02/hwlab-v02-frpc\`. +- G14 DEV/PROD boundary: this lane does not modify \`G14\`, \`G14-gitops\`, \`hwlab-dev\`, \`hwlab-prod\`, 17666/17667 or 18666/18667. +- Gate policy: old DEV/D601/main gates do not enter this lane; new checks stay limited to the fixed branch, namespace, catalog, runtime path and Argo boundaries. +`; + } return `# HWLAB G14 GitOps CI/CD This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.json\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`. @@ -2515,9 +3023,8 @@ This directory is generated from built-in Tekton-native CI primitive declaration } async function plannedFiles(args) { - const [deploy, artifactCatalog, namespaceTemplate, config, services, health, workloads] = await Promise.all([ + const [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([ readJson("deploy/deploy.json"), - readJson("deploy/artifact-catalog.dev.json"), readJson("deploy/k8s/base/namespace.yaml"), readJson("deploy/k8s/base/code-agent-codex-config.yaml"), readJson("deploy/k8s/base/services.yaml"), @@ -2525,36 +3032,44 @@ async function plannedFiles(args) { readJson("deploy/k8s/base/workloads.yaml") ]); const source = await resolveSourceRevision(args.sourceRevision); - const baseLabels = { "hwlab.pikastech.local/gitops-target": "g14", "hwlab.pikastech.local/source-commit": source.full }; + source.imageTag = imageTagForSource(args, source); + let artifactCatalog = await readJsonIfPresent(args.catalogPath, null); + if (!artifactCatalog && args.lane === "v02") artifactCatalog = artifactCatalogSkeleton({ args, source }); + if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath); + const settings = ciLaneSettings(args); + const profiles = laneRuntimeProfiles(args); + const migrationSql = args.lane === "v02" + ? await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8") + : null; + const baseLabels = { "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, "hwlab.pikastech.local/source-commit": source.full }; const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/g14-gitops-render.mjs" }; const files = new Map(); const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value)); const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value)); + const publicEndpoints = Object.fromEntries(profiles.map((profile) => { + const endpoints = profileEndpoint(args, profile); + return [profile, { web: endpoints.webEndpoint, runtime: endpoints.runtimeEndpoint }]; + })); const sourceDescriptor = { - kind: "hwlab-g14-gitops-source", + kind: args.lane === "v02" ? "hwlab-v02-gitops-source" : "hwlab-g14-gitops-source", sourceCommit: source.full, sourceShortCommit: source.short, + sourceImageTag: source.imageTag, sourceBranch: args.sourceBranch, gitopsBranch: args.gitopsBranch, + lane: settings.lane, sourceRepo: args.sourceRepo, registryPrefix: args.registryPrefix, - runtimePaths: ["deploy/gitops/g14/runtime-dev", "deploy/gitops/g14/runtime-prod"], - publicEndpoints: { - dev: { web: args.webEndpoint, runtime: args.runtimeEndpoint }, - prod: { web: args.prodWebEndpoint, runtime: args.prodRuntimeEndpoint } - }, - frpDeployment: "hwlab-dev/hwlab-g14-frpc", - frpDeployments: { - dev: "hwlab-dev/hwlab-g14-frpc", - prod: "hwlab-prod/hwlab-g14-prod-frpc" - }, - tektonPipeline: "hwlab-ci/hwlab-g14-ci-image-publish", - argoApplications: ["argocd/hwlab-g14-dev", "argocd/hwlab-g14-prod"] + runtimePaths: profiles.map((profile) => `deploy/gitops/g14/${runtimePathForProfile(profile)}`), + publicEndpoints, + frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${profile === "v02" ? "hwlab-v02-frpc" : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"}`])), + tektonPipeline: `hwlab-ci/${settings.pipelineName}`, + argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`) }; if (args.useDeployImages) { sourceDescriptor.renderMode = { - imageSource: "deploy/artifact-catalog.dev.json", + imageSource: args.catalogPath, configSource: "deploy/deploy.json", mixedDesiredState: true }; @@ -2562,16 +3077,15 @@ async function plannedFiles(args) { putJson("source.json", sourceDescriptor); putText("README.md", readme({ source, args })); putJson("registry/registry.yaml", registryManifest()); - putJson("tekton/rbac.yaml", tektonRbac()); - putJson("tekton/pipeline.yaml", tektonPipeline()); - putJson("tekton/poller.yaml", tektonPollerCronJob(args)); - putJson("tekton/control-plane-reconciler.yaml", tektonControlPlaneReconcilerCronJob(args)); - putJson("tekton/pipelinerun.sample.yaml", tektonPipelineRunTemplate({ source, args })); - putJson("argocd/project.yaml", argoProject()); - putJson("argocd/application-dev.yaml", argoApplication(args, "dev")); - putJson("argocd/application-prod.yaml", argoApplication(args, "prod")); + putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args)); + putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args)); + putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args)); + putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args)); + putJson(`${settings.tektonDir}/pipelinerun.sample.yaml`, tektonPipelineRunTemplate({ source, args })); + putJson("argocd/project.yaml", argoProject(args)); + for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile)); - for (const profile of ["dev", "prod"]) { + for (const profile of profiles) { const namespace = namespaceNameForProfile(profile); const includeDeviceAgent = profile === "dev"; const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) }; @@ -2588,6 +3102,7 @@ async function plannedFiles(args) { putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile)); putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); + if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql })); if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages })); putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile })); } diff --git a/scripts/refresh-artifact-catalog.mjs b/scripts/refresh-artifact-catalog.mjs index fdb74181..8f3a82d8 100644 --- a/scripts/refresh-artifact-catalog.mjs +++ b/scripts/refresh-artifact-catalog.mjs @@ -15,8 +15,9 @@ import { tempReportPath } from "./src/report-paths.mjs"; const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const catalogPath = "deploy/artifact-catalog.dev.json"; -const deployPath = "deploy/deploy.json"; +const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14"; +const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json"; +const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json"; const defaultPublishReportPath = tempReportPath("dev-artifacts.json"); const defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; const digestPattern = /^sha256:[a-f0-9]{64}$/; @@ -26,6 +27,10 @@ const publishReadyStatuses = new Set(["published", "reused"]); function parseArgs(argv) { const args = { + lane: defaultLane, + catalogPath: defaultCatalogPath, + deployPath: defaultDeployPath, + imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short", targetRef: "HEAD", publishReportPath: null, blocked: false, @@ -36,6 +41,14 @@ function parseArgs(argv) { const arg = argv[index]; if (arg === "--target-ref") { args.targetRef = readOption(argv, ++index, arg); + } else if (arg === "--lane") { + args.lane = readOption(argv, ++index, arg); + } else if (arg === "--catalog-path") { + args.catalogPath = readOption(argv, ++index, arg); + } else if (arg === "--deploy-json") { + args.deployPath = readOption(argv, ++index, arg); + } else if (arg === "--image-tag-mode") { + args.imageTagMode = readOption(argv, ++index, arg); } else if (arg === "--publish-report") { args.publishReportPath = readOption(argv, ++index, arg); } else if (arg === "--blocked") { @@ -52,6 +65,8 @@ function parseArgs(argv) { } if (!args.help) { + assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`); + assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`); assert.notEqual(args.blocked && Boolean(args.publishReportPath), true, "--blocked and --publish-report are mutually exclusive"); assert.ok(args.blocked || args.publishReportPath, `choose --blocked or --publish-report ${defaultPublishReportPath}`); assert.ok(!args.write || args.publishReportPath, "--write requires --publish-report; blocked skeleton refresh must stay preview-only"); @@ -78,7 +93,7 @@ function usage() { "examples:", " node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write", ` node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report ${defaultPublishReportPath} --no-write`, - ` node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --publish-report ${defaultPublishReportPath} --write` + ` node scripts/refresh-artifact-catalog.mjs --lane v02 --catalog-path deploy/artifact-catalog.v02.json --image-tag-mode full --target-ref HEAD --publish-report ${defaultPublishReportPath} --write` ].join("\n"); } @@ -87,6 +102,15 @@ async function readJson(relativePath) { return JSON.parse(await readFile(filePath, "utf8")); } +async function readJsonIfPresent(relativePath, fallback = null) { + try { + return await readJson(relativePath); + } catch (error) { + if (error?.code === "ENOENT") return fallback; + throw error; + } +} + async function writeJson(relativePath, value) { await writeFile(path.join(repoRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`); } @@ -100,11 +124,11 @@ async function gitValue(args) { return result.stdout.trim(); } -async function resolveTarget(ref) { +async function resolveTarget(ref, imageTagMode = "short") { const commitId = await gitValue(["rev-parse", ref]); const shortCommitId = await gitValue(["rev-parse", "--short=7", ref]); assert.match(commitId, /^[a-f0-9]{40}$/, `target ref ${ref} must resolve to a full SHA`); - return { ref, commitId, shortCommitId }; + return { ref, commitId, shortCommitId, imageTag: imageTagMode === "full" ? commitId : shortCommitId }; } function parseTaggedImage(image, context) { @@ -124,15 +148,73 @@ function targetImage(serviceId, shortCommitId, registryPrefix = defaultRegistryP return `${registryPrefix.replace(/\/+$/u, "")}/${serviceId}:${shortCommitId}`; } +function artifactEnvironment(args) { + return args.lane === "v02" ? "v02" : ENVIRONMENT_DEV; +} + +function artifactNamespace(args) { + return args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"; +} + +function artifactEndpoint(args) { + return args.lane === "v02" ? "http://74.48.78.17:19667" : DEV_ENDPOINT; +} + +function artifactCatalogSkeleton({ args, target, registryPrefix = defaultRegistryPrefix }) { + const environment = artifactEnvironment(args); + const namespace = artifactNamespace(args); + return { + catalogVersion: "v1", + kind: "hwlab-artifact-catalog", + environment, + profile: environment, + namespace, + endpoint: artifactEndpoint(args), + commitId: target.imageTag, + artifactState: "contract-skeleton", + publish: { + ciPublished: false, + registryVerified: false, + provenance: "not_available_until_publish", + note: `${environment} artifact catalog skeleton initialized without DEV/G14 fallback.` + }, + healthContract: { + method: "GET", + path: "/health/live", + responseFormat: "json", + requiredFields: ["serviceId", "environment", "status"] + }, + allowedProfiles: [environment], + forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"], + services: SERVICE_IDS.map((serviceId) => ({ + serviceId, + commitId: target.imageTag, + sourceCommitId: target.commitId, + image: targetImage(serviceId, target.imageTag, registryPrefix), + imageTag: target.imageTag, + digest: "not_published", + publishState: "skeleton-only", + profile: environment, + namespace, + healthPath: "/health/live", + sourceState: "source-present", + publishEnabled: true, + artifactRequired: true, + artifactScope: "required", + notPublishedReason: "publish_not_run" + })) + }; +} + function commitMatchesTarget(value, target) { return value === target.commitId || value === target.shortCommitId; } function shortCommitForRecord(record, target) { const value = record?.commitId ?? record?.imageTag ?? null; - if (typeof value === "string" && commitPattern.test(value)) return value.slice(0, 7); - if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return record.sourceCommitId.slice(0, 7); - return target.shortCommitId; + if (typeof value === "string" && commitPattern.test(value)) return target.imageTag.length === 40 ? value : value.slice(0, 7); + if (typeof record?.sourceCommitId === "string" && commitPattern.test(record.sourceCommitId)) return target.imageTag.length === 40 ? record.sourceCommitId : record.sourceCommitId.slice(0, 7); + return target.imageTag; } function revisionForRecord(record, target) { @@ -158,6 +240,27 @@ function assertDevOnlyDeployAndCatalog(deploy, catalog) { assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order"); } +function assertV02DeployAndCatalog(deploy, catalog) { + const lane = deploy?.lanes?.v02; + assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 catalog refresh"); + assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02"); + assert.equal(lane.endpoint, "http://74.48.78.17:19667", "deploy.lanes.v02.endpoint must be the v02 API endpoint"); + assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order"); + + assert.equal(catalog.environment, "v02", "catalog environment must be v02"); + assert.equal(catalog.profile, "v02", "catalog profile must be v02"); + assert.equal(catalog.namespace, "hwlab-v02", "catalog namespace must be hwlab-v02"); + assert.equal(catalog.endpoint, "http://74.48.78.17:19667", "catalog endpoint must be the v02 API endpoint"); + assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog allowedProfiles must only allow v02"); + assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog forbiddenProfiles must forbid dev/prod"); + assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order"); +} + +function assertLaneDeployAndCatalog(args, deploy, catalog) { + if (args.lane === "v02") return assertV02DeployAndCatalog(deploy, catalog); + return assertDevOnlyDeployAndCatalog(deploy, catalog); +} + function publishRecordsFromReport(report, target) { const artifactPublish = report.artifactPublish; assert.ok(artifactPublish && typeof artifactPublish === "object", "publish report missing artifactPublish"); @@ -198,7 +301,7 @@ function publishRecordsFromReport(report, target) { assert.equal(publishReadyStatuses.has(service.status), true, `${service.serviceId} status must be published or reused`); assert.equal(service.artifactRequired, true, `${service.serviceId} required service must state artifactRequired=true`); if (service.status === "published") { - assert.equal(image.tag, target.shortCommitId, `${service.serviceId} newly published image tag must match target short commit`); + assert.equal(image.tag, target.imageTag, `${service.serviceId} newly published image tag must match target image tag`); } assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`); records.set(service.serviceId, { @@ -238,7 +341,7 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve const inventoryByService = new Map(serviceInventory.services.map((service) => [service.serviceId, service])); const refreshedServices = []; - catalog.commitId = target.shortCommitId; + catalog.commitId = target.imageTag; catalog.artifactState = published ? "published" : "contract-skeleton"; catalog.serviceInventory = serviceInventory; catalog.publish = { @@ -260,8 +363,8 @@ function refreshDocuments({ deploy, catalog, target, publishRecords, serviceInve const inventory = inventoryByService.get(serviceId); const required = requiredIds.has(serviceId); const publishRecord = records?.get(serviceId) ?? null; - const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId, registryPrefix); - const imageTag = publishRecord?.imageTag ?? target.shortCommitId; + const image = publishRecord?.image ?? targetImage(serviceId, target.imageTag, registryPrefix); + const imageTag = publishRecord?.imageTag ?? target.imageTag; const serviceCommitId = shortCommitForRecord(publishRecord, target); catalogService.commitId = serviceCommitId; @@ -321,13 +424,15 @@ async function main() { return; } - const target = await resolveTarget(args.targetRef); - const [deploy, catalog, publishReport] = await Promise.all([ - readJson(deployPath), - readJson(catalogPath), + const target = await resolveTarget(args.targetRef, args.imageTagMode); + const [deploy, publishReport] = await Promise.all([ + readJson(args.deployPath), args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null) ]); - assertDevOnlyDeployAndCatalog(deploy, catalog); + let catalog = await readJsonIfPresent(args.catalogPath, null); + if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, target, registryPrefix: publishReport?.artifactPublish?.registryPrefix ?? defaultRegistryPrefix }); + assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} catalog refresh`); + assertLaneDeployAndCatalog(args, deploy, catalog); const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target) : null; const serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices( @@ -344,15 +449,18 @@ async function main() { }); if (args.write) { - await writeJson(catalogPath, catalog); + await writeJson(args.catalogPath, catalog); } console.log(JSON.stringify({ status: publishRecords ? "published" : "blocked", targetRef: target.ref, sourceCommitId: target.commitId, - artifactCommitId: target.shortCommitId, - wrote: args.write ? [catalogPath] : [], + artifactCommitId: target.imageTag, + lane: args.lane, + catalogPath: args.catalogPath, + imageTagMode: args.imageTagMode, + wrote: args.write ? [args.catalogPath] : [], deployTruthPolicy: "deploy.json is human-authored runtime config; artifact promotion must not rewrite it", ciPublished: Boolean(publishRecords), registryVerified: Boolean(publishRecords),