Add HWLAB v0.3 lane expansion config

This commit is contained in:
Codex Agent
2026-06-08 14:08:18 +08:00
parent 34ee947285
commit 6c5166d816
12 changed files with 852 additions and 149 deletions
+185 -139
View File
@@ -7,6 +7,14 @@ import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import {
applyRuntimeLaneDeployConfig,
defaultEndpointForRuntimeLane,
defaultPortForRuntimeLane,
isRuntimeLane,
runtimeLaneConfig,
versionNameForRuntimeLane
} from "./src/g14-gitops-lane.ts";
import { readStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
@@ -17,6 +25,8 @@ const defaultSourceRepo = process.env.HWLAB_G14_GIT_URL || "git@github.com:pikas
const defaultGitReadUrl = process.env.HWLAB_G14_GIT_READ_URL || null;
const defaultV02GitReadUrl = process.env.HWLAB_V02_GIT_READ_URL || "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git";
const defaultV02GitWriteUrl = process.env.HWLAB_V02_GIT_WRITE_URL || "http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/HWLAB.git";
const defaultRuntimeLaneGitReadUrl = process.env.HWLAB_RUNTIME_LANE_GIT_READ_URL || defaultV02GitReadUrl;
const defaultRuntimeLaneGitWriteUrl = process.env.HWLAB_RUNTIME_LANE_GIT_WRITE_URL || defaultV02GitWriteUrl;
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";
@@ -97,11 +107,12 @@ const primitiveValidationTasks = Object.freeze([
{
name: "g14-contract-check",
commands: [
"node --check scripts/g14-gitops-render.mjs",
"npm run gitops:ts:check",
"node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --help >/dev/null",
"render_check_revision=$(git rev-parse HEAD)",
"render_check_dir=$(mktemp -d)",
"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 scripts/run-bun.mjs 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/run-bun.mjs 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";
@@ -124,6 +135,7 @@ const forbiddenCiFragments = [
];
const scanTargets = [
"scripts/g14-gitops-render.mjs",
"scripts/src/g14-gitops-lane.ts",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/g14-ci-plan-lib.mjs",
@@ -234,23 +246,26 @@ 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 (isRuntimeLane(args.lane)) {
const versionName = versionNameForRuntimeLane(args.lane);
const defaultLaneEndpoint = defaultEndpointForRuntimeLane(args.lane);
if (args.sourceBranch === defaultBranch) args.sourceBranch = versionName;
if (args.gitopsBranch === defaultGitopsBranch) args.gitopsBranch = `${versionName}-gitops`;
if (args.catalogPath === defaultCatalogPath) args.catalogPath = `deploy/artifact-catalog.${args.lane}.json`;
if (args.runtimeEndpoint === defaultRuntimeEndpoint) args.runtimeEndpoint = defaultLaneEndpoint;
if (args.webEndpoint === defaultWebEndpoint) args.webEndpoint = defaultLaneEndpoint;
if (args.imageTagMode === "short") args.imageTagMode = "full";
if (!args.gitReadUrl) args.gitReadUrl = defaultV02GitReadUrl;
if (!args.gitWriteUrl) args.gitWriteUrl = defaultV02GitWriteUrl;
if (!args.gitReadUrl) args.gitReadUrl = defaultRuntimeLaneGitReadUrl;
if (!args.gitWriteUrl) args.gitWriteUrl = defaultRuntimeLaneGitWriteUrl;
}
if (!args.gitReadUrl) args.gitReadUrl = args.sourceRepo;
if (!args.gitWriteUrl) args.gitWriteUrl = args.sourceRepo;
assert.ok(["g14", "v02"].includes(args.lane), `unknown lane ${args.lane}`);
assert.ok(args.lane === "g14" || isRuntimeLane(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");
if (isRuntimeLane(args.lane)) {
const versionName = versionNameForRuntimeLane(args.lane);
assert.equal(args.sourceBranch, versionName, `${args.lane} source branch must be ${versionName}`);
assert.equal(args.gitopsBranch, `${versionName}-gitops`, `${args.lane} GitOps branch must be ${versionName}-gitops`);
}
return args;
}
@@ -263,24 +278,24 @@ function readOption(argv, index, name) {
function usage() {
return [
"usage: node scripts/g14-gitops-render.mjs [options]",
"usage: node scripts/run-bun.mjs scripts/g14-gitops-render.mjs [options]",
"",
"Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.",
"",
"options:",
" --lane LANE g14 or v02; default: g14",
" --lane LANE g14 or runtime lane such as v02/v03/v04; 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}`,
" --git-read-url URL read-only source URL; v02 defaults to devops-infra git mirror",
" --git-write-url URL GitOps write URL; v02 defaults to devops-infra git mirror write relay",
" --git-read-url URL read-only source URL; runtime lanes default to devops-infra git mirror",
" --git-write-url URL GitOps write URL; runtime lanes default to devops-infra git mirror write relay",
` --source-branch BRANCH default: ${defaultBranch}`,
` --gitops-branch BRANCH default: ${defaultGitopsBranch}`,
" --source-revision SHA source commit used for image tags and runtime annotations; default: git rev-parse HEAD",
` --registry-prefix PREFIX default: ${defaultRegistryPrefix}`,
` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}; v02 default: ${defaultV02RuntimeEndpoint}`,
` --web-endpoint URL default: ${defaultWebEndpoint}; v02 default: ${defaultV02WebEndpoint}`,
` --runtime-endpoint URL default: ${defaultRuntimeEndpoint}; runtime lane default is derived from deploy.yaml/public URL`,
` --web-endpoint URL default: ${defaultWebEndpoint}; runtime lane default is derived from deploy.yaml/public URL`,
` --prod-runtime-endpoint URL default: ${defaultProdRuntimeEndpoint}`,
` --prod-web-endpoint URL default: ${defaultProdWebEndpoint}`,
" --use-deploy-images default and only supported mode: render workload images from deploy/artifact-catalog.dev.json per service",
@@ -559,32 +574,38 @@ function runtimeImageTagForService(options) {
}
function artifactEnvironment(args) {
return args.lane === "v02" ? "v02" : "dev";
return isRuntimeLane(args.lane) ? args.lane : "dev";
}
function artifactEndpoint(args) {
return args.lane === "v02" ? defaultV02RuntimeEndpoint : defaultRuntimeEndpoint;
return isRuntimeLane(args.lane) ? args.runtimeEndpoint : defaultRuntimeEndpoint;
}
function serviceIdsForLane(lane, deploy = null) {
return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
return isRuntimeLane(lane) ? runtimeLaneServiceIdsFromDeploy(deploy, lane) : defaultServiceIds;
}
function serviceIdsForProfile(profile, deploy = null) {
return profile === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
return isRuntimeLane(profile) ? runtimeLaneServiceIdsFromDeploy(deploy, profile) : defaultServiceIds;
}
function v02ServiceIdsFromDeploy(deploy) {
const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : [])
function runtimeLaneServiceIdsFromDeploy(deploy, lane) {
const laneConfig = runtimeLaneConfig(deploy, lane);
const serviceIds = uniquePreserveOrder((Array.isArray(laneConfig.envReuseServices) ? laneConfig.envReuseServices : [])
.map((item) => String(item ?? "").trim())
.filter(Boolean));
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`);
return serviceIds;
}
function v02ObservableServicesForDeploy(deploy) {
const declarations = deploy?.lanes?.v02?.serviceDeclarations ?? {};
const serviceIds = new Set(v02ServiceIdsFromDeploy(deploy));
function v02ServiceIdsFromDeploy(deploy) {
return runtimeLaneServiceIdsFromDeploy(deploy, "v02");
}
function runtimeLaneObservableServicesForDeploy(deploy, lane) {
const laneConfig = runtimeLaneConfig(deploy, lane);
const declarations = laneConfig.serviceDeclarations ?? {};
const serviceIds = new Set(runtimeLaneServiceIdsFromDeploy(deploy, lane));
const declared = Object.entries(declarations)
.filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort))
.map(([serviceId, declaration]) => ({
@@ -595,12 +616,20 @@ function v02ObservableServicesForDeploy(deploy) {
return [...declared, ...v02ExtraObservableServices];
}
function v02ObservableServicesForDeploy(deploy) {
return runtimeLaneObservableServicesForDeploy(deploy, "v02");
}
function v02ObservableService(deploy, serviceId) {
return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null;
}
function runtimeLaneObservableServiceIdsForDeploy(deploy, lane) {
return new Set(runtimeLaneObservableServicesForDeploy(deploy, lane).map((item) => item.serviceId));
}
function v02ObservableServiceIdsForDeploy(deploy) {
return new Set(v02ObservableServicesForDeploy(deploy).map((item) => item.serviceId));
return runtimeLaneObservableServiceIdsForDeploy(deploy, "v02");
}
function servicesParamForLane(lane, deploy = null) {
@@ -624,7 +653,7 @@ function isV02RemovedServiceId(serviceId) {
function artifactCatalogSkeleton({ args, deploy, source }) {
const environment = artifactEnvironment(args);
const namespace = args.lane === "v02" ? "hwlab-v02" : "hwlab-dev";
const namespace = isRuntimeLane(args.lane) ? namespaceNameForProfile(args.lane) : "hwlab-dev";
const tag = imageTagForSource(args, source);
return {
catalogVersion: "v1",
@@ -642,7 +671,7 @@ function artifactCatalogSkeleton({ args, deploy, source }) {
publishedAt: null
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
forbiddenProfiles: isRuntimeLane(args.lane) ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }))
};
}
@@ -687,47 +716,48 @@ function artifactCatalogSkeletonService({ args, deploy, source, serviceId, envir
}
function namespaceNameForProfile(profile) {
if (profile === "v02") return "hwlab-v02";
if (isRuntimeLane(profile)) return `hwlab-${profile}`;
return profile === "prod" ? "hwlab-prod" : "hwlab-dev";
}
function runtimePathForProfile(profile) {
if (profile === "v02") return "runtime-v02";
if (isRuntimeLane(profile)) return `runtime-${profile}`;
return profile === "prod" ? "runtime-prod" : "runtime-dev";
}
function runtimeLabelForProfile(profile) {
if (profile === "v02") return "v02";
if (isRuntimeLane(profile)) return profile;
return profile === "prod" ? "prod" : "dev";
}
function gitopsTargetForProfile(profile) {
return profile === "v02" ? "v02" : "g14";
return isRuntimeLane(profile) ? profile : "g14";
}
function profileEndpoint(args, profile) {
if (profile === "v02") return { runtimeEndpoint: args.runtimeEndpoint, webEndpoint: args.webEndpoint };
if (isRuntimeLane(profile)) 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"];
return isRuntimeLane(args.lane) ? [args.lane] : ["dev", "prod"];
}
function laneNames(args) {
if (args.lane === "v02") {
if (isRuntimeLane(args.lane)) {
const namespace = namespaceNameForProfile(args.lane);
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"]
gitopsTarget: args.lane,
pipeline: `${namespace}-ci-image-publish`,
poller: `${namespace}-branch-poller`,
reconciler: `${namespace}-control-plane-reconciler`,
serviceAccount: `${namespace}-tekton-runner`,
pipelineRunPrefix: `${namespace}-ci-poll-`,
runtimeNamespace: namespace,
runtimePath: `deploy/gitops/g14/runtime-${args.lane}`,
argoApplications: [`argocd/hwlab-g14-${args.lane}`]
};
}
return {
@@ -744,7 +774,7 @@ function laneNames(args) {
}
function argoApplicationName(profile) {
return profile === "v02" ? "hwlab-g14-v02" : `hwlab-g14-${profile}`;
return `hwlab-g14-${profile}`;
}
function imageTagForSource(args, source) {
@@ -1126,13 +1156,13 @@ function transformListNamespace(value, namespace, labels, annotations) {
}
function transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) {
const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set();
const observableServiceIds = isRuntimeLane(profile) ? runtimeLaneObservableServiceIdsForDeploy(deploy, profile) : new Set();
const result = transformListNamespace(services, namespace, labels, annotations);
if (result.kind === "List") {
result.items = (result.items ?? []).filter((item) => !(profile === "v02" && isV02RemovedServiceId(serviceIdForWorkload(item, null))));
result.items = (result.items ?? []).filter((item) => !(isRuntimeLane(profile) && isV02RemovedServiceId(serviceIdForWorkload(item, null))));
for (const item of result.items) {
const serviceId = serviceIdForWorkload(item, null);
if (profile !== "v02" || !v02ObservableServiceIds.has(serviceId)) continue;
if (!isRuntimeLane(profile) || !observableServiceIds.has(serviceId)) continue;
label(item.metadata ??= {}, v02MetricsLabels(serviceId));
upsertV02MetricsPort(item);
}
@@ -1140,17 +1170,18 @@ function transformServices({ services, namespace, labels, annotations, profile =
return result;
}
function observabilityManifest({ deploy, namespace, labels, annotations, metricsSidecarScript }) {
function observabilityManifest({ deploy, profile, namespace, labels, annotations, metricsSidecarScript }) {
assert.ok(isRuntimeLane(profile), `observability profile must be a runtime lane, got ${profile}`);
const baseLabels = {
...labels,
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/monitoring": "enabled"
};
const serviceMonitors = v02ObservableServicesForDeploy(deploy).map((service) => ({
const serviceMonitors = runtimeLaneObservableServicesForDeploy(deploy, profile).map((service) => ({
apiVersion: "monitoring.coreos.com/v1",
kind: "ServiceMonitor",
metadata: {
name: `hwlab-v02-${service.serviceId}`,
name: `hwlab-${profile}-${service.serviceId}`,
namespace,
labels: { ...baseLabels, "hwlab.pikastech.local/service-id": service.serviceId },
annotations
@@ -1159,7 +1190,7 @@ function observabilityManifest({ deploy, namespace, labels, annotations, metrics
namespaceSelector: { matchNames: [namespace] },
selector: {
matchLabels: {
"hwlab.pikastech.local/gitops-target": "v02",
"hwlab.pikastech.local/gitops-target": profile,
"hwlab.pikastech.local/monitoring": "enabled",
"hwlab.pikastech.local/service-id": service.serviceId
}
@@ -1178,30 +1209,30 @@ function observabilityManifest({ deploy, namespace, labels, annotations, metrics
{
apiVersion: "v1",
kind: "ConfigMap",
metadata: { name: "hwlab-v02-metrics-sidecar", namespace, labels: baseLabels, annotations },
metadata: { name: `hwlab-${profile}-metrics-sidecar`, namespace, labels: baseLabels, annotations },
data: { "metrics-sidecar.mjs": metricsSidecarScript }
},
...serviceMonitors,
{
apiVersion: "monitoring.coreos.com/v1",
kind: "PrometheusRule",
metadata: { name: "hwlab-v02-observability", namespace, labels: baseLabels, annotations },
metadata: { name: `hwlab-${profile}-observability`, namespace, labels: baseLabels, annotations },
spec: {
groups: [{
name: "hwlab-v02-observability",
name: `hwlab-${profile}-observability`,
rules: [
{
alert: "HWLABV02MetricsTargetDown",
expr: 'up{namespace="hwlab-v02"} == 0',
alert: `HWLAB${profile.toUpperCase()}MetricsTargetDown`,
expr: `up{namespace="${namespace}"} == 0`,
for: "5m",
labels: { severity: "warning", lane: "v02" },
annotations: { summary: "HWLAB v0.2 metrics target is down", description: "Prometheus cannot scrape a HWLAB v0.2 metrics target." }
labels: { severity: "warning", lane: profile },
annotations: { summary: `HWLAB ${versionNameForRuntimeLane(profile)} metrics target is down`, description: `Prometheus cannot scrape a HWLAB ${versionNameForRuntimeLane(profile)} metrics target.` }
},
{
alert: "HWLABV02ServiceHealthProbeFailed",
expr: 'hwlab_service_health_probe_success{namespace="hwlab-v02"} == 0',
alert: `HWLAB${profile.toUpperCase()}ServiceHealthProbeFailed`,
expr: `hwlab_service_health_probe_success{namespace="${namespace}"} == 0`,
for: "5m",
labels: { severity: "warning", lane: "v02" },
labels: { severity: "warning", lane: profile },
annotations: { summary: "HWLAB v0.2 service health probe failed", description: "The metrics sidecar cannot reach the service health endpoint inside the pod network." }
}
]
@@ -1920,8 +1951,8 @@ if [ -s /workspace/source/dev-artifacts.json ]; then
node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-config deploy/deploy.yaml --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 --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
node scripts/run-bun.mjs 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/run-bun.mjs 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)"
@@ -2087,8 +2118,8 @@ revision="$(git rev-parse HEAD)"
: "\${LANE:?LANE is required}"
: "\${CATALOG_PATH:?CATALOG_PATH is required}"
: "\${IMAGE_TAG_MODE:?IMAGE_TAG_MODE is required}"
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 scripts/run-bun.mjs 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/run-bun.mjs 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");
@@ -2357,20 +2388,21 @@ NODE
function ciLaneSettings(argsOrLane = "g14") {
const lane = typeof argsOrLane === "string" ? argsOrLane : argsOrLane.lane;
if (lane === "v02") {
if (isRuntimeLane(lane)) {
const namespace = namespaceNameForProfile(lane);
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"
lane,
profile: lane,
gitopsTarget: lane,
pipelineName: `${namespace}-ci-image-publish`,
pipelineRunPrefix: `${namespace}-ci-poll`,
serviceAccountName: `${namespace}-tekton-runner`,
pollerName: `${namespace}-branch-poller`,
controlPlaneReconcilerName: `${namespace}-control-plane-reconciler`,
tektonDir: `tekton-${lane}`,
catalogPath: typeof argsOrLane === "object" ? argsOrLane.catalogPath : `deploy/artifact-catalog.${lane}.json`,
imageTagMode: typeof argsOrLane === "object" ? argsOrLane.imageTagMode : "full",
runtimeNamespace: namespace
};
}
return {
@@ -2391,7 +2423,10 @@ function ciLaneSettings(argsOrLane = "g14") {
function tektonRbac(args = { lane: "g14" }) {
const settings = ciLaneSettings(args);
if (settings.lane === "v02") {
if (isRuntimeLane(settings.lane)) {
const runtimeObserverName = `${settings.runtimeNamespace}-runtime-observer`;
const pipelineRunWriterName = `${settings.runtimeNamespace}-pipelinerun-writer`;
const argoReconcilerName = `${settings.runtimeNamespace}-argocd-reconciler`;
return {
apiVersion: "v1",
kind: "List",
@@ -2401,7 +2436,7 @@ function tektonRbac(args = { lane: "g14" }) {
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" },
metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace },
rules: [
{ apiGroups: [""], resources: ["pods", "services", "configmaps"], verbs: ["get", "list", "watch"] },
{ apiGroups: ["apps"], resources: ["deployments", "statefulsets"], verbs: ["get", "list", "watch"] },
@@ -2411,14 +2446,14 @@ function tektonRbac(args = { lane: "g14" }) {
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: "hwlab-v02-runtime-observer", namespace: "hwlab-v02" },
metadata: { name: runtimeObserverName, namespace: settings.runtimeNamespace },
subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-runtime-observer" }
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: runtimeObserverName }
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" },
metadata: { name: pipelineRunWriterName, namespace: "hwlab-ci" },
rules: [
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns"], verbs: ["get", "list", "create"] },
{ apiGroups: ["tekton.dev"], resources: ["pipelines"], verbs: ["get", "patch", "create"] },
@@ -2430,14 +2465,14 @@ function tektonRbac(args = { lane: "g14" }) {
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: "hwlab-v02-pipelinerun-writer", namespace: "hwlab-ci" },
metadata: { name: pipelineRunWriterName, 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" }
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: pipelineRunWriterName }
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" },
metadata: { name: argoReconcilerName, 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"] }
@@ -2446,9 +2481,9 @@ function tektonRbac(args = { lane: "g14" }) {
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: "hwlab-v02-argocd-reconciler", namespace: "argocd" },
metadata: { name: argoReconcilerName, namespace: "argocd" },
subjects: [{ kind: "ServiceAccount", name: settings.serviceAccountName, namespace: "hwlab-ci" }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "hwlab-v02-argocd-reconciler" }
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: argoReconcilerName }
}
]
};
@@ -4004,15 +4039,16 @@ function contentType(filePath) {
}
function argoProject(args = { lane: "g14" }) {
if (args.lane === "v02") {
if (isRuntimeLane(args.lane)) {
const namespace = namespaceNameForProfile(args.lane);
return {
apiVersion: "argoproj.io/v1alpha1",
kind: "AppProject",
metadata: { name: "hwlab-v02", namespace: "argocd" },
metadata: { name: namespace, namespace: "argocd" },
spec: {
description: "HWLAB v0.2 additive GitOps project on G14; DEV/PROD remain outside this lane.",
description: `HWLAB ${versionNameForRuntimeLane(args.lane)} additive GitOps project on G14; DEV/PROD remain outside this lane.`,
sourceRepos: [args.gitReadUrl],
destinations: [{ server: "https://kubernetes.default.svc", namespace: "hwlab-v02" }],
destinations: [{ server: "https://kubernetes.default.svc", namespace }],
clusterResourceWhitelist: [{ group: "", kind: "Namespace" }],
namespaceResourceWhitelist: [{ group: "*", kind: "*" }]
}
@@ -4039,8 +4075,8 @@ 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";
const repoURL = profile === "v02" ? args.gitReadUrl : args.sourceRepo;
const project = isRuntimeLane(profile) ? namespace : "hwlab-g14";
const repoURL = isRuntimeLane(profile) ? args.gitReadUrl : args.sourceRepo;
return {
apiVersion: "argoproj.io/v1alpha1",
kind: "Application",
@@ -4053,7 +4089,7 @@ function argoApplication(args, profile = "dev") {
project,
source: { repoURL, targetRevision: args.gitopsBranch, path: `deploy/gitops/g14/${runtimePath}` },
destination: { server: "https://kubernetes.default.svc", namespace },
syncPolicy: { automated: { prune: profile === "v02", selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] }
syncPolicy: { automated: { prune: isRuntimeLane(profile), selfHeal: true }, syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"] }
}
};
}
@@ -4061,11 +4097,11 @@ function argoApplication(args, profile = "dev") {
function g14FrpcManifest({ profile = "dev", source = null } = {}) {
const namespace = namespaceNameForProfile(profile);
const profileLabel = runtimeLabelForProfile(profile);
const deploymentName = profile === "v02" ? "hwlab-v02-frpc" : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc";
const deploymentName = isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc";
const configName = `${deploymentName}-config`;
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 proxyPrefix = isRuntimeLane(profile) ? `hwlab-${profile}` : profile === "prod" ? "hwlab-g14-prod" : "hwlab-g14";
const webRemotePort = isRuntimeLane(profile) ? defaultPortForRuntimeLane(profile) : profile === "prod" ? 18666 : 17666;
const edgeRemotePort = isRuntimeLane(profile) ? webRemotePort + 1 : profile === "prod" ? 18667 : 17667;
const labels = {
"app.kubernetes.io/name": deploymentName,
"app.kubernetes.io/part-of": "hwlab",
@@ -4075,7 +4111,7 @@ function g14FrpcManifest({ profile = "dev", source = null } = {}) {
};
const sourceCommitLabel = source?.full ? { "hwlab.pikastech.local/source-commit": source.full } : {};
const renderedLabels = { ...labels, ...sourceCommitLabel };
const podNetwork = profile === "v02" ? {
const podNetwork = isRuntimeLane(profile) ? {
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet"
} : {};
@@ -4589,24 +4625,25 @@ function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, ca
};
}
function v02PostgresManifest({ migrationSql, source }) {
const namespace = "hwlab-v02";
const name = "hwlab-v02-postgres";
function v02PostgresManifest({ profile = "v02", migrationSql, source }) {
const namespace = namespaceNameForProfile(profile);
const name = `${namespace}-postgres`;
const dbName = `hwlab_${profile}`;
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",
"hwlab.pikastech.local/environment": profile,
"hwlab.pikastech.local/gitops-target": profile,
"hwlab.pikastech.local/profile": profile,
"hwlab.pikastech.local/source-commit": source.full
};
const migrationSha256 = createHash("sha256").update(migrationSql).digest("hex");
const templateLabels = {
"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",
"hwlab.pikastech.local/environment": profile,
"hwlab.pikastech.local/gitops-target": profile,
"hwlab.pikastech.local/profile": profile,
"hwlab.pikastech.local/migration-sha256": k8sLabelHash(migrationSha256)
};
const templateAnnotations = { "hwlab.pikastech.local/migration-sha256": migrationSha256 };
@@ -4642,9 +4679,9 @@ function v02PostgresManifest({ migrationSql, source }) {
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" } } }
{ name: "POSTGRES_DB", value: dbName },
{ name: "POSTGRES_USER", value: dbName },
{ name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name, key: "POSTGRES_PASSWORD" } } }
],
ports: [{ name: "postgres", containerPort: 5432 }],
readinessProbe: { tcpSocket: { port: "postgres" }, initialDelaySeconds: 5, periodSeconds: 10 },
@@ -4667,17 +4704,17 @@ function v02PostgresManifest({ migrationSql, source }) {
};
}
function v02OpenFgaManifest({ source }) {
const namespace = "hwlab-v02";
function v02OpenFgaManifest({ profile = "v02", source }) {
const namespace = namespaceNameForProfile(profile);
const name = "hwlab-openfga";
const image = "127.0.0.1:5000/hwlab/openfga:v1.17.0";
const labels = {
"app.kubernetes.io/name": name,
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/component": "authorization",
"hwlab.pikastech.local/environment": "v02",
"hwlab.pikastech.local/gitops-target": "v02",
"hwlab.pikastech.local/profile": "v02",
"hwlab.pikastech.local/environment": profile,
"hwlab.pikastech.local/gitops-target": profile,
"hwlab.pikastech.local/profile": profile,
"hwlab.pikastech.local/service-id": name,
"hwlab.pikastech.local/source-commit": source.full
};
@@ -4685,9 +4722,9 @@ function v02OpenFgaManifest({ source }) {
const selector = { "app.kubernetes.io/name": name };
const env = [
{ name: "OPENFGA_DATASTORE_ENGINE", value: "postgres" },
{ name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "datastore-uri" } } },
{ name: "OPENFGA_DATASTORE_URI", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "datastore-uri" } } },
{ name: "OPENFGA_AUTHN_METHOD", value: "preshared" },
{ name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: "hwlab-v02-openfga", key: "authn-preshared-key" } } },
{ name: "OPENFGA_AUTHN_PRESHARED_KEYS", valueFrom: { secretKeyRef: { name: `hwlab-${profile}-openfga`, key: "authn-preshared-key" } } },
{ name: "OPENFGA_PLAYGROUND_ENABLED", value: "false" },
{ name: "OPENFGA_HTTP_ADDR", value: "0.0.0.0:8080" },
{ name: "OPENFGA_GRPC_ADDR", value: "0.0.0.0:8081" }
@@ -4815,17 +4852,26 @@ async function plannedFiles(args) {
readJson("deploy/k8s/dev/health-contract.yaml"),
readJson("deploy/k8s/base/workloads.yaml")
]);
args = applyRuntimeLaneDeployConfig(args, deploy, {
defaultBranch,
defaultGitopsBranch,
defaultCatalogPath,
defaultRuntimeEndpoint,
defaultWebEndpoint,
defaultV02RuntimeEndpoint,
defaultSourceRepo
});
const source = await resolveSourceRevision(args.sourceRevision);
source.imageTag = imageTagForSource(args, source);
let artifactCatalog = await readJsonIfPresent(args.catalogPath, null);
if (!artifactCatalog && args.lane === "v02") artifactCatalog = artifactCatalogSkeleton({ args, deploy, source });
if (!artifactCatalog && isRuntimeLane(args.lane)) artifactCatalog = artifactCatalogSkeleton({ args, deploy, source });
if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath);
const settings = ciLaneSettings(args);
const profiles = laneRuntimeProfiles(args);
const migrationSql = args.lane === "v02"
const migrationSql = isRuntimeLane(args.lane)
? await readFile(path.join(repoRoot, "internal/db/migrations/0001_cloud_core_skeleton.sql"), "utf8")
: null;
const metricsSidecarScript = args.lane === "v02"
const metricsSidecarScript = isRuntimeLane(args.lane)
? await readFile(path.join(repoRoot, "internal/dev-entrypoint/metrics-sidecar.mjs"), "utf8")
: null;
const metricsSidecarSha256 = metricsSidecarScript ? createHash("sha256").update(metricsSidecarScript).digest("hex") : null;
@@ -4840,7 +4886,7 @@ async function plannedFiles(args) {
}));
const sourceDescriptor = {
kind: args.lane === "v02" ? "hwlab-v02-gitops-source" : "hwlab-g14-gitops-source",
kind: isRuntimeLane(args.lane) ? `hwlab-${args.lane}-gitops-source` : "hwlab-g14-gitops-source",
sourceCommit: source.full,
sourceShortCommit: source.short,
sourceImageTag: source.imageTag,
@@ -4853,7 +4899,7 @@ async function plannedFiles(args) {
registryPrefix: args.registryPrefix,
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"}`])),
frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-g14-prod-frpc" : "hwlab-g14-frpc"}`])),
tektonPipeline: `hwlab-ci/${settings.pipelineName}`,
argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`)
};
@@ -4867,10 +4913,10 @@ async function plannedFiles(args) {
putJson("source.json", sourceDescriptor);
putText("README.md", readme({ source, args }));
putJson("registry/registry.yaml", registryManifest());
if (args.lane === "v02") putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest());
if (isRuntimeLane(args.lane)) putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest(args));
putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args));
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy));
if (args.lane !== "v02") {
if (!isRuntimeLane(args.lane)) {
putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args, deploy));
putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args));
}
@@ -4890,14 +4936,14 @@ async function plannedFiles(args) {
const endpoints = profileEndpoint(args, profile);
putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent }));
putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
if (profile !== "v02") putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy }));
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, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql, source }));
if (profile === "v02") putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ source }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSql, source }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source }));
if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
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, source }));
}
@@ -4913,8 +4959,8 @@ async function writeFiles(files) {
}
async function cleanStaleGeneratedOutput(args) {
if (args.lane !== "v02") return;
for (const relativePath of ["runtime-v02", "tekton-v02"]) {
if (!isRuntimeLane(args.lane)) return;
for (const relativePath of [`runtime-${args.lane}`, `tekton-${args.lane}`]) {
await rm(generatedPath(path.join(args.outDir, relativePath)), { recursive: true, force: true });
}
}
+1
View File
@@ -90,6 +90,7 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
await writeFile(staleTektonFile, "hwlab-router\n");
const render = spawnSync(process.execPath, [
"scripts/run-bun.mjs",
"scripts/g14-gitops-render.mjs",
"--lane", "v02",
"--catalog-path", "deploy/artifact-catalog.v02.json",
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readDeployConfig, writeDeployConfig } from "./src/deploy-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultDeployPath = "deploy/deploy.yaml";
const defaultHost = "74.48.78.17";
const args = parseArgs(process.argv.slice(2));
if (args.help) {
process.stdout.write(`${usage()}\n`);
process.exit(0);
}
const deploy = await readDeployConfig(repoRoot, args.deployConfig);
const result = await configureLane(deploy, args);
if (args.write) {
await writeDeployConfig(repoRoot, args.deployConfig, deploy);
for (const file of result.copiedFiles) {
await mkdir(path.dirname(path.join(repoRoot, file.to)), { recursive: true });
await writeFile(path.join(repoRoot, file.to), file.content, "utf8");
}
}
process.stdout.write(`${JSON.stringify({
ok: true,
command: "hwlab-lane-expand",
action: args.write ? "configured" : "planned",
deployConfig: args.deployConfig,
lane: result.lane,
fromLane: result.fromLane,
wrote: args.write,
copiedFiles: result.copiedFiles.map((file) => ({ from: file.from, to: file.to })),
config: laneSummary(result.config),
next: {
render: `node scripts/run-bun.mjs scripts/g14-gitops-render.mjs --lane ${result.lane} --source-branch ${result.config.sourceBranch} --gitops-branch ${result.config.gitopsBranch} --catalog-path ${result.config.artifactCatalog} --image-tag-mode ${result.config.imageTagMode ?? "full"}`,
controlPlane: `bun scripts/cli.ts hwlab g14 control-plane apply --lane ${result.lane} --confirm`,
trigger: `bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane ${result.lane} --confirm`
}
}, null, 2)}\n`);
function parseArgs(argv) {
const parsed = {
action: "configure",
lane: null,
fromLane: "v02",
deployConfig: defaultDeployPath,
host: defaultHost,
write: false,
force: false,
copyLaneFiles: true,
help: false,
webPort: null,
apiPort: null,
publicUrl: null
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "configure" || arg === "plan") parsed.action = arg;
else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg);
else if (arg === "--from") parsed.fromLane = readOption(argv, ++index, arg);
else if (arg === "--deploy-config") parsed.deployConfig = readOption(argv, ++index, arg);
else if (arg === "--host") parsed.host = readOption(argv, ++index, arg);
else if (arg === "--web-port") parsed.webPort = parsePort(readOption(argv, ++index, arg), arg);
else if (arg === "--api-port") parsed.apiPort = parsePort(readOption(argv, ++index, arg), arg);
else if (arg === "--public-url") parsed.publicUrl = readOption(argv, ++index, arg).replace(/\/+$/u, "");
else if (arg === "--write") parsed.write = true;
else if (arg === "--force") parsed.force = true;
else if (arg === "--no-copy-lane-files") parsed.copyLaneFiles = false;
else if (arg === "--help" || arg === "-h") parsed.help = true;
else throw new Error(`unknown argument ${arg}`);
}
if (!parsed.help) {
assertLane(parsed.lane, "--lane");
assertLane(parsed.fromLane, "--from");
assert.notEqual(parsed.lane, parsed.fromLane, "--lane must differ from --from");
}
return parsed;
}
function readOption(argv, index, name) {
const value = argv[index];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function parsePort(value, name) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) throw new Error(`${name} must be a TCP port`);
return parsed;
}
function usage() {
return [
"usage: node scripts/hwlab-lane-expand.mjs configure --lane v03 [--from v02] [--write]",
"",
"Create or preview a new HWLAB additive runtime lane by editing deploy/deploy.yaml only.",
"The command derives branch, namespace, GitOps branch, artifact catalog, runtime path, FRP ports,",
"and lane-specific SecretRef names from the target lane id so v04/v05/v06 do not require code edits.",
"",
"options:",
" --lane LANE target lane id such as v03, v04, v05",
" --from LANE source lane template; default: v02",
" --deploy-config PATH default: deploy/deploy.yaml",
" --host HOST public host for default legacy endpoints; default: 74.48.78.17",
" --web-port PORT override derived web FRP port",
" --api-port PORT override derived API FRP port",
" --public-url URL override derived HTTPS public endpoint",
" --write write deploy config and copied lane prompt files",
" --force replace an existing target lane",
" --no-copy-lane-files do not copy lane-scoped source files referenced by config"
].join("\n");
}
async function configureLane(deploy, options) {
deploy.lanes ??= {};
const source = deploy.lanes[options.fromLane];
assert.ok(source && typeof source === "object", `deploy.lanes.${options.fromLane} is required`);
if (deploy.lanes[options.lane] && !options.force) {
throw new Error(`deploy.lanes.${options.lane} already exists; pass --force to replace it`);
}
const sourceInfo = laneInfo(options.fromLane, { host: options.host });
const targetInfo = laneInfo(options.lane, {
host: options.host,
webPort: options.webPort,
apiPort: options.apiPort,
publicUrl: options.publicUrl
});
const replacements = laneReplacements(source, sourceInfo, targetInfo);
const target = deepTransform(clone(source), replacements);
target.name = targetInfo.versionName;
target.sourceBranch = targetInfo.sourceBranch;
target.gitopsBranch = targetInfo.gitopsBranch;
target.namespace = targetInfo.namespace;
target.endpoint = targetInfo.publicUrl;
target.publicEndpoints = {
...(target.publicEndpoints ?? {}),
frontend: targetInfo.publicUrl,
api: targetInfo.publicUrl,
legacyFrontend: `http://${options.host}:${targetInfo.webPort}`,
legacyApi: `http://${options.host}:${targetInfo.apiPort}`
};
target.artifactCatalog = `deploy/artifact-catalog.${options.lane}.json`;
target.runtimePath = `deploy/gitops/g14/runtime-${options.lane}`;
target.imageTagMode = target.imageTagMode ?? "full";
deploy.lanes[options.lane] = target;
const copiedFiles = options.copyLaneFiles ? await copiedLaneFiles(target, replacements, sourceInfo, targetInfo) : [];
return { lane: options.lane, fromLane: options.fromLane, config: target, copiedFiles };
}
function assertLane(value, label) {
if (!/^v[0-9]{2,}$/u.test(String(value ?? ""))) throw new Error(`${label} must look like v03, v04, v05`);
}
function laneInfo(lane, options = {}) {
assertLane(lane, "lane");
const minor = Number.parseInt(lane.slice(1), 10);
const webPort = options.webPort ?? 19466 + minor * 100;
const apiPort = options.apiPort ?? webPort + 1;
const versionName = `v0.${minor}`;
const host = options.host ?? defaultHost;
const hostLabel = host.replaceAll(".", "-");
return {
lane,
minor,
host,
versionName,
sourceBranch: versionName,
gitopsBranch: `${versionName}-gitops`,
namespace: `hwlab-${lane}`,
webPort,
apiPort,
publicUrl: options.publicUrl ?? `https://hwlab-${lane}.${hostLabel}.nip.io`
};
}
function laneReplacements(sourceConfig, sourceInfo, targetInfo) {
const sourceLegacy = sourceConfig?.publicEndpoints ?? {};
return [
[sourceInfo.versionName, targetInfo.versionName],
[sourceInfo.sourceBranch, targetInfo.sourceBranch],
[sourceInfo.gitopsBranch, targetInfo.gitopsBranch],
[sourceInfo.namespace, targetInfo.namespace],
[sourceInfo.lane, targetInfo.lane],
[`runtime-${sourceInfo.lane}`, `runtime-${targetInfo.lane}`],
[`artifact-catalog.${sourceInfo.lane}.json`, `artifact-catalog.${targetInfo.lane}.json`],
[`usr_${sourceInfo.lane}_admin`, `usr_${targetInfo.lane}_admin`],
[`${sourceInfo.lane}-redacted-presence-only`, `${targetInfo.lane}-redacted-presence-only`],
[String(sourceLegacy.legacyFrontend ?? `http://${sourceInfo.host}:${sourceInfo.webPort}`), `http://${targetInfo.host}:${targetInfo.webPort}`],
[String(sourceLegacy.legacyApi ?? `http://${sourceInfo.host}:${sourceInfo.apiPort}`), `http://${targetInfo.host}:${targetInfo.apiPort}`],
[String(sourceLegacy.frontend ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl],
[String(sourceLegacy.api ?? sourceConfig?.endpoint ?? ""), targetInfo.publicUrl]
].filter(([from]) => from);
}
function deepTransform(value, replacements) {
if (typeof value === "string") return replaceAll(value, replacements);
if (Array.isArray(value)) return value.map((item) => deepTransform(item, replacements));
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [replaceAll(key, replacements), deepTransform(item, replacements)]));
}
return value;
}
function replaceAll(value, replacements) {
let text = String(value);
for (const [from, to] of replacements) text = text.split(from).join(to);
return text;
}
async function copiedLaneFiles(target, replacements, sourceInfo, targetInfo) {
const paths = new Set();
collectRepoPaths(target, paths);
const copied = [];
for (const targetPath of paths) {
if (!targetPath.includes(targetInfo.lane) && !targetPath.includes(targetInfo.versionName)) continue;
const sourcePath = targetPath
.split(targetInfo.lane).join(sourceInfo.lane)
.split(targetInfo.versionName).join(sourceInfo.versionName);
const sourceAbs = path.join(repoRoot, sourcePath);
const targetAbs = path.join(repoRoot, targetPath);
if (sourceAbs === targetAbs) continue;
let raw;
try {
raw = await readFile(sourceAbs, "utf8");
} catch (error) {
if (error?.code === "ENOENT") continue;
throw error;
}
try {
await readFile(targetAbs, "utf8");
continue;
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
copied.push({ from: sourcePath, to: targetPath, content: replaceAll(raw, replacements) });
}
return copied;
}
function collectRepoPaths(value, result) {
if (typeof value === "string") {
if (/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u.test(value) && !value.startsWith("http")) result.add(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectRepoPaths(item, result);
return;
}
if (value && typeof value === "object") {
for (const item of Object.values(value)) collectRepoPaths(item, result);
}
}
function laneSummary(config) {
return {
name: config.name,
sourceBranch: config.sourceBranch,
gitopsBranch: config.gitopsBranch,
namespace: config.namespace,
artifactCatalog: config.artifactCatalog,
runtimePath: config.runtimePath,
publicEndpoints: config.publicEndpoints,
serviceCount: Array.isArray(config.envReuseServices) ? config.envReuseServices.length : null
};
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
+3 -1
View File
@@ -40,7 +40,9 @@ export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([
export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([
"deploy/gitops/",
"deploy/frp/",
"scripts/g14-gitops-render.mjs"
"scripts/g14-gitops-render.mjs",
"scripts/src/g14-gitops-lane.ts",
"tsconfig.gitops.json"
]);
export async function createG14CiPlan(options = {}) {
+120
View File
@@ -0,0 +1,120 @@
type RuntimeLaneDefaults = {
defaultBranch?: string;
defaultGitopsBranch?: string;
defaultCatalogPath?: string;
defaultRuntimeEndpoint?: string;
defaultWebEndpoint?: string;
defaultV02RuntimeEndpoint?: string;
defaultSourceRepo?: string;
};
export type GitOpsRenderArgs = {
lane: string;
sourceBranch: string;
gitopsBranch: string;
catalogPath: string;
imageTagMode: string;
runtimeEndpoint: string;
webEndpoint: string;
sourceRepo: string;
[key: string]: unknown;
};
export type RuntimeLaneConfig = {
sourceBranch?: string;
gitopsBranch?: string;
artifactCatalog?: string;
imageTagMode?: string;
endpoint?: string;
publicEndpoints?: {
frontend?: string;
api?: string;
[key: string]: string | undefined;
};
sourceRepo?: string;
};
export type DeployConfig = {
lanes?: Record<string, RuntimeLaneConfig | undefined>;
};
const defaultPublicHost = "74.48.78.17";
export function isRuntimeLane(lane: unknown): lane is string {
return /^v[0-9]{2,}$/u.test(String(lane ?? ""));
}
export function runtimeLaneMinor(lane: string): number {
invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`);
return Number.parseInt(lane.slice(1), 10);
}
export function versionNameForRuntimeLane(lane: string): string {
return `v0.${runtimeLaneMinor(lane)}`;
}
export function namespaceNameForRuntimeLane(lane: string): string {
invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`);
return `hwlab-${lane}`;
}
export function runtimePathForRuntimeLane(lane: string): string {
invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`);
return `runtime-${lane}`;
}
export function defaultPortForRuntimeLane(lane: string): number {
return 19466 + runtimeLaneMinor(lane) * 100;
}
export function publicHostLabel(host = defaultPublicHost): string {
return host.replaceAll(".", "-");
}
export function defaultEndpointForRuntimeLane(lane: string, options: { host?: string; v02Endpoint?: string } = {}): string {
if (lane === "v02" && options.v02Endpoint) return options.v02Endpoint;
return `https://hwlab-${lane}.${publicHostLabel(options.host ?? defaultPublicHost)}.nip.io`;
}
export function runtimeLaneConfig(deploy: DeployConfig, lane: string): RuntimeLaneConfig {
invariant(isRuntimeLane(lane), `runtime lane must look like v02/v03, got ${lane}`);
const config = deploy.lanes?.[lane];
invariant(isObject(config), `deploy.lanes.${lane} is required`);
return config;
}
export function applyRuntimeLaneDeployConfig(args: GitOpsRenderArgs, deploy: DeployConfig, defaults: RuntimeLaneDefaults = {}): GitOpsRenderArgs {
if (!isRuntimeLane(args.lane)) return args;
const result: GitOpsRenderArgs = { ...args };
const laneConfig = runtimeLaneConfig(deploy, args.lane);
const versionName = versionNameForRuntimeLane(args.lane);
const defaultEndpointOptions = defaults.defaultV02RuntimeEndpoint ? { v02Endpoint: defaults.defaultV02RuntimeEndpoint } : {};
const defaultEndpoint = defaultEndpointForRuntimeLane(args.lane, defaultEndpointOptions);
const configuredEndpoint = laneConfig.endpoint ?? defaultEndpoint;
const configuredWebEndpoint = laneConfig.publicEndpoints?.frontend ?? configuredEndpoint;
const configuredRuntimeEndpoint = laneConfig.publicEndpoints?.api ?? configuredEndpoint;
const defaultBranch = defaults.defaultBranch ?? "G14";
const defaultGitopsBranch = defaults.defaultGitopsBranch ?? "G14-gitops";
const defaultCatalogPath = defaults.defaultCatalogPath ?? "deploy/artifact-catalog.dev.json";
const defaultRuntimeEndpoint = defaults.defaultRuntimeEndpoint ?? "http://74.48.78.17:17667";
const defaultWebEndpoint = defaults.defaultWebEndpoint ?? "http://74.48.78.17:17666";
const defaultSourceRepo = defaults.defaultSourceRepo ?? "git@github.com:pikasTech/HWLAB.git";
const defaultCatalog = `deploy/artifact-catalog.${args.lane}.json`;
if (result.sourceBranch === defaultBranch || result.sourceBranch === versionName) result.sourceBranch = laneConfig.sourceBranch ?? versionName;
if (result.gitopsBranch === defaultGitopsBranch || result.gitopsBranch === `${versionName}-gitops`) result.gitopsBranch = laneConfig.gitopsBranch ?? `${versionName}-gitops`;
if (result.catalogPath === defaultCatalogPath || result.catalogPath === defaultCatalog) result.catalogPath = laneConfig.artifactCatalog ?? defaultCatalog;
if (result.imageTagMode === "full") result.imageTagMode = laneConfig.imageTagMode ?? result.imageTagMode;
if (result.runtimeEndpoint === defaultRuntimeEndpoint || result.runtimeEndpoint === defaultEndpoint) result.runtimeEndpoint = configuredRuntimeEndpoint;
if (result.webEndpoint === defaultWebEndpoint || result.webEndpoint === defaultEndpoint) result.webEndpoint = configuredWebEndpoint;
if (result.sourceRepo === defaultSourceRepo && laneConfig.sourceRepo) result.sourceRepo = laneConfig.sourceRepo;
return result;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function invariant(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}