Merge remote-tracking branch 'origin/master' into feat/2010-hwlab-release-production-lane
This commit is contained in:
@@ -441,6 +441,24 @@ function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string {
|
||||
"cd \"$root/gitops\"",
|
||||
"git fetch origin \"$(params.gitops-branch)\" || true",
|
||||
"if git rev-parse --verify \"refs/remotes/origin/$(params.gitops-branch)^{commit}\" >/dev/null 2>&1; then git checkout -B \"$(params.gitops-branch)\" \"refs/remotes/origin/$(params.gitops-branch)\"; else git checkout --orphan \"$(params.gitops-branch)\"; git rm -rf . >/dev/null 2>&1 || true; fi",
|
||||
"incoming_source_commit='$(params.revision)'",
|
||||
"source_branch='$(params.source-branch)'",
|
||||
"branch_source_commit=$(git -C \"$root/repo\" rev-parse --verify \"refs/remotes/origin/${source_branch}^{commit}\")",
|
||||
"gitops_source_commit=$(node -e 'try { const value = JSON.parse(require(\"node:fs\").readFileSync(\"source.json\", \"utf8\")).sourceCommit; if (typeof value === \"string\") process.stdout.write(value); } catch {}')",
|
||||
"stale_reason=''",
|
||||
"if [ \"$incoming_source_commit\" != \"$branch_source_commit\" ]; then stale_reason='stale-source-revision'; fi",
|
||||
"if [ -z \"$stale_reason\" ] && [ -n \"$gitops_source_commit\" ] && [ \"$incoming_source_commit\" != \"$gitops_source_commit\" ]; then",
|
||||
" if ! git -C \"$root/repo\" cat-file -e \"${gitops_source_commit}^{commit}\" 2>/dev/null; then stale_reason='gitops-source-order-unverifiable';",
|
||||
" elif git -C \"$root/repo\" merge-base --is-ancestor \"$incoming_source_commit\" \"$gitops_source_commit\"; then stale_reason='stale-gitops-source-revision';",
|
||||
" elif ! git -C \"$root/repo\" merge-base --is-ancestor \"$gitops_source_commit\" \"$incoming_source_commit\"; then stale_reason='divergent-gitops-source-revision'; fi",
|
||||
"fi",
|
||||
"if [ -n \"$stale_reason\" ]; then",
|
||||
" gitops_commit=$(git rev-parse HEAD)",
|
||||
" INCOMING_SOURCE_COMMIT=\"$incoming_source_commit\" CURRENT_SOURCE_COMMIT=\"$branch_source_commit\" GITOPS_SOURCE_COMMIT=\"$gitops_source_commit\" GITOPS_COMMIT=\"$gitops_commit\" STALE_REASON=\"$stale_reason\" node <<'NODE'",
|
||||
"console.log(JSON.stringify({ ok: true, status: 'succeeded', phase: 'gitops-publish', reason: process.env.STALE_REASON, warning: true, blocking: false, changed: false, stale: true, gitopsCommit: process.env.GITOPS_COMMIT, sourceCommit: process.env.INCOMING_SOURCE_COMMIT, currentSourceCommit: process.env.CURRENT_SOURCE_COMMIT, gitopsSourceCommit: process.env.GITOPS_SOURCE_COMMIT || null, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
" exit 0",
|
||||
"fi",
|
||||
"git rm -rf --ignore-unmatch \"$(params.gitops-root)\" \"$(params.artifact-catalog)\" source.json >/dev/null 2>&1 || true",
|
||||
"rm -rf \"$(params.gitops-root)\" \"$(params.artifact-catalog)\" source.json",
|
||||
"TEMPLATES_B64=\"$templates_b64\" BUILD_RESULT=\"$build_result\" node <<'NODE'",
|
||||
|
||||
@@ -21,11 +21,13 @@ test("shared stage injects owning YAML OTel values for all three PaC repositorie
|
||||
renderedRootExpression: "'/workspace/rendered'",
|
||||
});
|
||||
expect(stage).toContain("feature-config-schema-validation");
|
||||
expect(stage).toContain("cicd.feature_config.schema");
|
||||
expect(stage).toContain("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'");
|
||||
expect(stage).toContain("OTEL_TRACES_SAMPLER_ARG='1'");
|
||||
expect(stage).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(stage).toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
expect(stage).toContain("base64 -d | gzip -d");
|
||||
expect(stage).toContain("NODE_UNIDESK_FEATURE_CONFIG_SCHEMA");
|
||||
expect(stage).not.toContain("cicd.feature_config.schema");
|
||||
expect(stage).not.toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
expect(stage).not.toContain("process.env.webProbeSentinel");
|
||||
}
|
||||
});
|
||||
@@ -112,7 +114,9 @@ test("AgentRun normal GitOps publish task contains the shared warning-only stage
|
||||
expect(scripts).toContain("feature-config-schema-validation");
|
||||
expect(scripts).toContain("feature-config-validator-process-failure");
|
||||
expect(scripts).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'");
|
||||
expect(scripts).toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
expect(scripts).toContain("base64 -d | gzip -d");
|
||||
expect(scripts).toContain("NODE_UNIDESK_FEATURE_CONFIG_SCHEMA");
|
||||
expect(scripts).not.toContain("NODE_UNIDESK_AJV2020_BUNDLE");
|
||||
});
|
||||
|
||||
test("repo-owned schema is Draft 2020-12 with one canonical feature property", () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
import { loadCicdOtelRuntimeConfig } from "../native/cicd/otel-runtime-config";
|
||||
|
||||
const validatorSource = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd();
|
||||
const ajv2020BundleSource = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd();
|
||||
const validatorGzipBase64 = gzipBase64(validatorSource);
|
||||
const ajv2020GzipBase64 = gzipBase64(ajv2020BundleSource);
|
||||
|
||||
export interface PacFeatureConfigSchemaStageOptions {
|
||||
readonly repositoryId: string;
|
||||
@@ -26,14 +29,21 @@ export function renderPacFeatureConfigSchemaStage(options: PacFeatureConfigSchem
|
||||
`export OTEL_SERVICE_NAME=${shellSingle(otel.serviceName)}`,
|
||||
`export OTEL_TRACES_SAMPLER_ARG=${shellSingle(String(otel.samplingRatio))}`,
|
||||
`export OTEL_EXPORTER_TIMEOUT_MS=${shellSingle(String(otel.timeoutMs))}`,
|
||||
"feature_config_validator_base=''",
|
||||
"feature_config_validator=''",
|
||||
"if feature_config_validator_base=\"$(mktemp \"${TMPDIR:-/tmp}/unidesk-feature-config-validator.XXXXXX\" 2>/dev/null)\"; then",
|
||||
" feature_config_validator=\"${feature_config_validator_base}.mjs\"",
|
||||
" if ! printf '%s' " + shellSingle(validatorGzipBase64) + " | base64 -d | gzip -d >\"$feature_config_validator\"; then",
|
||||
" rm -f \"$feature_config_validator_base\" \"$feature_config_validator\" || true",
|
||||
" feature_config_validator=''",
|
||||
" fi",
|
||||
" rm -f \"$feature_config_validator_base\" || true",
|
||||
"fi",
|
||||
...(materializeBundle
|
||||
? [
|
||||
"feature_config_ajv_bundle=''",
|
||||
"if feature_config_ajv_bundle=\"$(mktemp \"${TMPDIR:-/tmp}/unidesk-ajv2020.XXXXXX\" 2>/dev/null)\"; then",
|
||||
" if ! cat >\"$feature_config_ajv_bundle\" <<'NODE_UNIDESK_AJV2020_BUNDLE'",
|
||||
ajv2020BundleSource,
|
||||
"NODE_UNIDESK_AJV2020_BUNDLE",
|
||||
" then",
|
||||
" if ! printf '%s' " + shellSingle(ajv2020GzipBase64) + " | base64 -d | gzip -d >\"$feature_config_ajv_bundle\"; then",
|
||||
" rm -f \"$feature_config_ajv_bundle\" || true",
|
||||
" feature_config_ajv_bundle=''",
|
||||
" fi",
|
||||
@@ -41,17 +51,16 @@ export function renderPacFeatureConfigSchemaStage(options: PacFeatureConfigSchem
|
||||
"export UNIDESK_AJV2020_BUNDLE=\"${feature_config_ajv_bundle:-${TMPDIR:-/tmp}/unidesk-ajv2020-unavailable}\"",
|
||||
]
|
||||
: [`export UNIDESK_AJV2020_BUNDLE=${options.ajvBundleExpression}`]),
|
||||
"export UNIDESK_FEATURE_CONFIG_VALIDATOR=\"${feature_config_validator:-${TMPDIR:-/tmp}/unidesk-feature-config-validator-unavailable.mjs}\"",
|
||||
"if ! node --input-type=module <<'NODE_UNIDESK_FEATURE_CONFIG_SCHEMA'",
|
||||
validatorSource,
|
||||
"await runFeatureConfigSchemaValidation({",
|
||||
" repoDir: process.env.UNIDESK_FEATURE_CONFIG_REPO_DIR,",
|
||||
" renderedRoot: process.env.UNIDESK_FEATURE_CONFIG_RENDERED_ROOT,",
|
||||
" consumer: process.env.UNIDESK_PAC_CONSUMER,",
|
||||
"});",
|
||||
"import { pathToFileURL } from 'node:url';",
|
||||
"const validator = await import(pathToFileURL(process.env.UNIDESK_FEATURE_CONFIG_VALIDATOR).href);",
|
||||
"await validator.runFeatureConfigSchemaValidation({ repoDir: process.env.UNIDESK_FEATURE_CONFIG_REPO_DIR, renderedRoot: process.env.UNIDESK_FEATURE_CONFIG_RENDERED_ROOT, consumer: process.env.UNIDESK_PAC_CONSUMER });",
|
||||
"NODE_UNIDESK_FEATURE_CONFIG_SCHEMA",
|
||||
"then",
|
||||
" printf '{\"event\":\"feature-config-schema-validation\",\"warning\":true,\"blocking\":false,\"code\":\"feature-config-validator-process-failure\",\"mutation\":false,\"valuesPrinted\":false}\\n' >&2",
|
||||
"fi",
|
||||
"if [ -n \"$feature_config_validator\" ]; then rm -f \"$feature_config_validator\" || true; fi",
|
||||
...(materializeBundle ? ["if [ -n \"$feature_config_ajv_bundle\" ]; then rm -f \"$feature_config_ajv_bundle\" || true; fi"] : []),
|
||||
"if command -v ci_timing_emit >/dev/null 2>&1; then",
|
||||
" ci_timing_emit feature-config-schema-validation succeeded \"$feature_config_schema_started_ms\"",
|
||||
@@ -64,3 +73,7 @@ export function renderPacFeatureConfigSchemaStage(options: PacFeatureConfigSchem
|
||||
function shellSingle(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function gzipBase64(value: string): string {
|
||||
return gzipSync(Buffer.from(value, "utf8"), { level: 9 }).toString("base64");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
|
||||
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
|
||||
import { renderMirrorBootstrap } from "./platform-infra-gitea-render";
|
||||
import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
|
||||
import { parsePacConfigDocument, readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code";
|
||||
import { parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code";
|
||||
import { materializeYamlComposition } from "./yaml-composition";
|
||||
|
||||
const mirror: GiteaMirrorRepository = {
|
||||
@@ -185,6 +185,38 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu
|
||||
expect(statusAction).toContain("blocking: false");
|
||||
});
|
||||
|
||||
test("PaC release manifest renders YAML-owned watcher startup backlog probes", () => {
|
||||
const pac = readPacConfig({ consumerId: "hwlab-nc01-v03", selectDefault: true });
|
||||
const manifest = renderPacReleaseManifest(pac, `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: pipelines-as-code-watcher
|
||||
namespace: pipelines-as-code
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: pac-watcher
|
||||
image: example.invalid/pac-watcher:v0
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /live
|
||||
port: probes
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: pipelines-as-code-watcher
|
||||
namespace: pipelines-as-code
|
||||
`);
|
||||
const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean).map((item) => Bun.YAML.parse(item) as any);
|
||||
const watcher = documents[0].spec.template.spec.containers[0];
|
||||
expect(watcher.startupProbe).toEqual(pac.release.workloads.watcher.startupProbe);
|
||||
expect(watcher.readinessProbe).toEqual(pac.release.workloads.watcher.readinessProbe);
|
||||
expect(watcher.livenessProbe).toEqual(pac.release.workloads.watcher.livenessProbe);
|
||||
expect(documents[1].kind).toBe("Service");
|
||||
});
|
||||
|
||||
test("platform bootstrap help advertises the composite entry before low-level apply", async () => {
|
||||
const help = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]);
|
||||
const usage = (help as Record<string, unknown>).usage as string[];
|
||||
|
||||
@@ -71,6 +71,26 @@ interface PacTarget {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PacHttpProbe {
|
||||
httpGet: {
|
||||
path: string;
|
||||
port: string;
|
||||
};
|
||||
initialDelaySeconds: number;
|
||||
periodSeconds: number;
|
||||
timeoutSeconds: number;
|
||||
failureThreshold: number;
|
||||
successThreshold: number;
|
||||
}
|
||||
|
||||
interface PacWorkloadProbes {
|
||||
deploymentName: string;
|
||||
containerName: string;
|
||||
startupProbe: PacHttpProbe;
|
||||
readinessProbe: PacHttpProbe;
|
||||
livenessProbe: PacHttpProbe;
|
||||
}
|
||||
|
||||
export interface PacConfig {
|
||||
version: number;
|
||||
kind: "platform-infra-pipelines-as-code";
|
||||
@@ -104,6 +124,9 @@ export interface PacConfig {
|
||||
controllerServiceName: string;
|
||||
controllerServicePort: number;
|
||||
waitTimeoutSeconds: number;
|
||||
workloads: {
|
||||
watcher: PacWorkloadProbes;
|
||||
};
|
||||
};
|
||||
deliveryProvenance: {
|
||||
version: string;
|
||||
@@ -474,6 +497,8 @@ export function parsePacConfigDocument(
|
||||
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {},
|
||||
): PacConfig {
|
||||
const release = y.objectField(root, "release", "");
|
||||
const releaseWorkloads = y.objectField(release, "workloads", "release");
|
||||
const watcherWorkload = y.objectField(releaseWorkloads, "watcher", "release.workloads");
|
||||
const deliveryProvenance = y.objectField(root, "deliveryProvenance", "");
|
||||
const provenanceController = y.objectField(deliveryProvenance, "controller", "deliveryProvenance");
|
||||
const provenanceAdmission = y.objectField(deliveryProvenance, "admission", "deliveryProvenance");
|
||||
@@ -542,6 +567,9 @@ export function parsePacConfigDocument(
|
||||
controllerServiceName: y.kubernetesNameField(release, "controllerServiceName", "release"),
|
||||
controllerServicePort: y.portField(release, "controllerServicePort", "release"),
|
||||
waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"),
|
||||
workloads: {
|
||||
watcher: parsePacWorkloadProbes(watcherWorkload, "release.workloads.watcher"),
|
||||
},
|
||||
},
|
||||
deliveryProvenance: {
|
||||
version: y.stringField(deliveryProvenance, "version", "deliveryProvenance"),
|
||||
@@ -1714,7 +1742,35 @@ async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
|
||||
if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`);
|
||||
const text = await response.text();
|
||||
if (!text.includes("repositories.pipelinesascode.tekton.dev")) throw new Error(`${pac.release.manifestUrl} did not contain the Repository CRD`);
|
||||
return text;
|
||||
return renderPacReleaseManifest(pac, text);
|
||||
}
|
||||
|
||||
export function renderPacReleaseManifest(pac: PacConfig, source: string): string {
|
||||
const workload = pac.release.workloads.watcher;
|
||||
let matched = false;
|
||||
const documents = source
|
||||
.split(/^---\s*$/mu)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => Bun.YAML.parse(item) as unknown)
|
||||
.map((item) => {
|
||||
const document = record(item);
|
||||
const metadata = record(document.metadata);
|
||||
if (document.kind !== "Deployment" || metadata.name !== workload.deploymentName || metadata.namespace !== pac.release.namespace) return document;
|
||||
const spec = record(document.spec);
|
||||
const template = record(spec.template);
|
||||
const podSpec = record(template.spec);
|
||||
const containers = arrayRecords(podSpec.containers);
|
||||
const container = containers.find((candidate) => candidate.name === workload.containerName);
|
||||
if (container === undefined) throw new Error(`${configLabel}.release.workloads.watcher.containerName did not match ${workload.deploymentName}`);
|
||||
container.startupProbe = structuredClone(workload.startupProbe);
|
||||
container.readinessProbe = structuredClone(workload.readinessProbe);
|
||||
container.livenessProbe = structuredClone(workload.livenessProbe);
|
||||
matched = true;
|
||||
return document;
|
||||
});
|
||||
if (!matched) throw new Error(`${configLabel}.release.workloads.watcher.deploymentName did not match the release manifest`);
|
||||
return `${documents.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
||||
}
|
||||
|
||||
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
|
||||
@@ -3187,6 +3243,31 @@ function positiveInteger(obj: Record<string, unknown>, key: string, path: string
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePacHttpProbe(obj: Record<string, unknown>, path: string): PacHttpProbe {
|
||||
const httpGet = y.objectField(obj, "httpGet", path);
|
||||
return {
|
||||
httpGet: {
|
||||
path: y.stringField(httpGet, "path", `${path}.httpGet`),
|
||||
port: y.stringField(httpGet, "port", `${path}.httpGet`),
|
||||
},
|
||||
initialDelaySeconds: positiveInteger(obj, "initialDelaySeconds", path),
|
||||
periodSeconds: positiveInteger(obj, "periodSeconds", path),
|
||||
timeoutSeconds: positiveInteger(obj, "timeoutSeconds", path),
|
||||
failureThreshold: positiveInteger(obj, "failureThreshold", path),
|
||||
successThreshold: positiveInteger(obj, "successThreshold", path),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePacWorkloadProbes(obj: Record<string, unknown>, path: string): PacWorkloadProbes {
|
||||
return {
|
||||
deploymentName: y.kubernetesNameField(obj, "deploymentName", path),
|
||||
containerName: y.kubernetesNameField(obj, "containerName", path),
|
||||
startupProbe: parsePacHttpProbe(y.objectField(obj, "startupProbe", path), `${path}.startupProbe`),
|
||||
readinessProbe: parsePacHttpProbe(y.objectField(obj, "readinessProbe", path), `${path}.readinessProbe`),
|
||||
livenessProbe: parsePacHttpProbe(y.objectField(obj, "livenessProbe", path), `${path}.livenessProbe`),
|
||||
};
|
||||
}
|
||||
|
||||
function urlField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = y.stringField(obj, key, path);
|
||||
const parsed = new URL(value);
|
||||
|
||||
Reference in New Issue
Block a user