diff --git a/docs/dev-gate-report.md b/docs/dev-gate-report.md index 35f4cb25..fa1d242c 100644 --- a/docs/dev-gate-report.md +++ b/docs/dev-gate-report.md @@ -25,6 +25,11 @@ Each committed DEV gate report must carry `reportLifecycle`: - Active reports may preserve a legacy public endpoint only inside an explicit `runtimeSmoke.legacyPublicEndpoint` object with `status: "deprecated"` and `activeGreenEligible: false`. +- Active reports must not present `:6666` or `:6667` as a current public DEV + endpoint in endpoint, route, health, ingress, API, frontend, or browser + evidence. Internal k3s service/listen ports may still be `6667` when the + surrounding field names them as cluster-local service ports rather than + public endpoints. - M3 DEV-LIVE remains narrower than report freshness: it requires `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` in real DEV plus operation, audit, and evidence identifiers. SOURCE, LOCAL, DRY-RUN, @@ -40,6 +45,9 @@ Each committed DEV gate report must carry `reportLifecycle`: - `reports/dev-gate/dev-gate-report.example.json`: machine-readable example. - `reports/dev-gate/dev-deploy-report.json`: DEV deploy apply/preflight result. - `scripts/validate-dev-gate-report.mjs`: local validator for the report shape. +- `scripts/report-lifecycle.mjs`: lightweight lifecycle helper for validating + one report or marking a replaced report historical/deprecated after endpoint + changes. ## Required Fields @@ -105,3 +113,24 @@ node scripts/validate-dev-gate-report.mjs The validator scans `reports/dev-gate/*.json` by default. It only checks the contract shape and the frozen report metadata; it does not run any DEV or PROD deployment. + +## Invalidate/Rebuild Workflow + +When the public DEV endpoint mapping changes, do not rewrite historical +evidence strings. Preserve the old facts and mark any report that can no longer +represent the active gate as historical: + +```sh +node --check scripts/report-lifecycle.mjs +node scripts/report-lifecycle.mjs invalidate reports/dev-gate/.json \ + --deprecated-endpoint http://74.48.78.17:6667 \ + --reason "Public DEV endpoint moved to frontend :16666 and API/edge :16667" \ + --out reports/dev-gate/historical/-legacy-6667.json +node scripts/validate-dev-gate-report.mjs reports/dev-gate/historical/-legacy-6667.json +``` + +Then regenerate the active report through its owning script so the active file +uses `http://74.48.78.17:16666` for browser/frontend evidence and +`http://74.48.78.17:16667` for API/edge/live evidence. Keep PR notes explicit: +which legacy reports were moved or marked historical, which active reports were +regenerated, and which current reports still need real DEV reruns. diff --git a/internal/dev-report-lifecycle.mjs b/internal/dev-report-lifecycle.mjs index 3955b22c..a86c4f9e 100644 --- a/internal/dev-report-lifecycle.mjs +++ b/internal/dev-report-lifecycle.mjs @@ -4,15 +4,55 @@ export const DEPRECATED_DEV_PUBLIC_ENDPOINT = "http://74.48.78.17:6667"; export const DEPRECATED_RESERVED_PROD_ENDPOINT = "http://74.48.78.17:6666"; export const REPORT_LIFECYCLE_VERSION = "v1"; +export const deprecatedPublicPorts = Object.freeze([6666, 6667]); export const deprecatedPublicEndpoints = Object.freeze([ DEPRECATED_DEV_PUBLIC_ENDPOINT, DEPRECATED_RESERVED_PROD_ENDPOINT ]); -const deprecatedPublicEndpointPattern = /http:\/\/74\.48\.78\.17:666[67]\b/gu; +const deprecatedPublicEndpointPattern = /\bhttps?:\/\/74\.48\.78\.17:(666[67])(?=[/?#\s"'`),\]}]|$)/giu; +const deprecatedPublicHostPortPattern = /(?", + " node scripts/report-lifecycle.mjs invalidate --reason [--deprecated-endpoint ] [--replaced-by ] [--out ]", + "", + "Notes:", + " validate checks the active/historical endpoint lifecycle contract for one report.", + " invalidate marks one report historical/deprecated without changing evidence payloads.", + " --out defaults to in-place; use a historical/ path when keeping an old active filename reserved for regenerated evidence." + ].join("\n"); +} + +function parseArgs(argv) { + if (argv.length === 0 || argv.includes("--help")) { + return { help: true }; + } + const [command, reportPath, ...rest] = argv; + const args = { + command, + reportPath, + deprecatedEndpoint: defaultDeprecatedEndpoint, + reason: null, + replacedBy: null, + out: null + }; + + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]; + if (arg === "--deprecated-endpoint") { + args.deprecatedEndpoint = rest[++index]; + } else if (arg === "--reason") { + args.reason = rest[++index]; + } else if (arg === "--replaced-by") { + args.replacedBy = rest[++index]; + } else if (arg === "--out") { + args.out = rest[++index]; + } else if (arg === "--help") { + args.help = true; + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + if (args.help || !args.command) return args; + if (!["validate", "invalidate"].includes(args.command)) { + throw new Error(`unknown command: ${args.command}`); + } + assert.ok(args.reportPath, "report path is required"); + if (args.command === "invalidate") { + assert.ok(args.reason, "--reason is required for invalidate"); + assert.ok( + deprecatedPublicEndpoints.includes(args.deprecatedEndpoint), + `--deprecated-endpoint must be one of ${deprecatedPublicEndpoints.join(", ")}` + ); + } + return args; +} + +function repoPath(inputPath) { + const absolutePath = path.resolve(process.cwd(), inputPath); + assert.ok( + absolutePath === repoRoot || absolutePath.startsWith(`${repoRoot}${path.sep}`), + `${inputPath} must stay inside the repository` + ); + return absolutePath; +} + +function relativeRepoPath(inputPath) { + return path.relative(repoRoot, repoPath(inputPath)).split(path.sep).join("/"); +} + +function assertObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); +} + +function assertString(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.ok(value.trim().length > 0, `${label} must not be empty`); +} + +function validateLifecycle(report, raw, label) { + assertObject(report.reportLifecycle, `${label}.reportLifecycle`); + const lifecycle = report.reportLifecycle; + assert.equal(lifecycle.version, REPORT_LIFECYCLE_VERSION, `${label}.reportLifecycle.version`); + assert.ok(["active", "historical"].includes(lifecycle.state), `${label}.reportLifecycle.state`); + assert.equal(lifecycle.activeEndpoint, ACTIVE_DEV_PUBLIC_ENDPOINT, `${label}.reportLifecycle.activeEndpoint`); + assert.equal(lifecycle.activeBrowserEndpoint, ACTIVE_DEV_BROWSER_ENDPOINT, `${label}.reportLifecycle.activeBrowserEndpoint`); + assertString(lifecycle.summary, `${label}.reportLifecycle.summary`); + + if (lifecycle.state === "active") { + assert.equal(lifecycle.deprecatedEndpoint, null, `${label}.reportLifecycle.deprecatedEndpoint`); + assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints(report, raw), + [], + `${label} active report must not present :6666/:6667 as current public endpoint evidence` + ); + return "active"; + } + + assert.ok( + deprecatedPublicEndpoints.includes(lifecycle.deprecatedEndpoint), + `${label}.reportLifecycle.deprecatedEndpoint must name a deprecated public endpoint` + ); + assert.ok( + lifecycle.summary.toLowerCase().includes("historical") || + lifecycle.summary.toLowerCase().includes("deprecated"), + `${label}.reportLifecycle.summary must explain the historical/deprecated state` + ); + return "historical"; +} + +function primaryStatus(report) { + return report.status ?? report.gateStatus ?? report.conclusion ?? null; +} + +function markNonGreen(report) { + let hasTopLevelStatus = false; + for (const field of statusFields) { + if (Object.hasOwn(report, field)) { + hasTopLevelStatus = true; + if (["pass", "ready", "green", "published"].includes(report[field])) { + report[field] = "blocked"; + } + } + } + if (!hasTopLevelStatus) { + report.status = "blocked"; + } +} + +function appendInvalidationBlocker(report, reason, deprecatedEndpoint) { + if (!Array.isArray(report.blockers)) { + report.blockers = []; + } + const scope = "deprecated-public-endpoint"; + const exists = report.blockers.some((blocker) => blocker?.scope === scope); + if (!exists) { + report.blockers.push({ + type: "environment_blocker", + scope, + status: "open", + summary: `${reason} Deprecated endpoint ${deprecatedEndpoint} cannot be used as active DEV evidence.` + }); + } +} + +async function readReport(reportPath) { + const absolutePath = repoPath(reportPath); + const raw = await readFile(absolutePath, "utf8"); + return { absolutePath, raw, report: JSON.parse(raw) }; +} + +async function validateCommand(args) { + const relativePath = relativeRepoPath(args.reportPath); + const { raw, report } = await readReport(args.reportPath); + const state = validateLifecycle(report, raw, relativePath); + process.stdout.write(`${JSON.stringify({ + ok: true, + path: relativePath, + state, + primaryStatus: primaryStatus(report) + })}\n`); +} + +async function invalidateCommand(args) { + const inputRelativePath = relativeRepoPath(args.reportPath); + const { report } = await readReport(args.reportPath); + assertObject(report, inputRelativePath); + + report.reportLifecycle = historicalReportLifecycle({ + deprecatedEndpoint: args.deprecatedEndpoint, + summary: `Historical/deprecated after DEV public endpoint change: ${args.reason}` + }); + if (args.replacedBy) { + report.reportLifecycle.replacedBy = relativeRepoPath(args.replacedBy); + } + markNonGreen(report); + appendInvalidationBlocker(report, args.reason, args.deprecatedEndpoint); + + const outPath = args.out ? repoPath(args.out) : repoPath(args.reportPath); + await mkdir(path.dirname(outPath), { recursive: true }); + const nextRaw = `${JSON.stringify(report, null, 2)}\n`; + validateLifecycle(report, nextRaw, inputRelativePath); + await writeFile(outPath, nextRaw); + process.stdout.write(`${JSON.stringify({ + ok: true, + input: inputRelativePath, + output: path.relative(repoRoot, outPath).split(path.sep).join("/"), + state: "historical", + deprecatedEndpoint: args.deprecatedEndpoint, + replacedBy: report.reportLifecycle.replacedBy ?? null + })}\n`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.command) { + process.stdout.write(`${usage()}\n`); + return; + } + if (args.command === "validate") { + await validateCommand(args); + return; + } + if (args.command === "invalidate") { + await invalidateCommand(args); + } +} + +try { + await main(); +} catch (error) { + process.stderr.write(`[report-lifecycle] ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/scripts/report-lifecycle.test.mjs b/scripts/report-lifecycle.test.mjs new file mode 100644 index 00000000..356819f0 --- /dev/null +++ b/scripts/report-lifecycle.test.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; + +import { + DEPRECATED_DEV_PUBLIC_ENDPOINT, + DEPRECATED_RESERVED_PROD_ENDPOINT, + findDeprecatedPublicEndpoints, + findForbiddenActiveDeprecatedEndpoints +} from "../internal/dev-report-lifecycle.mjs"; + +const activeReport = { + reportLifecycle: { + state: "active" + } +}; + +const activeReportWithLegacyException = { + reportLifecycle: { + state: "active" + }, + runtimeSmoke: { + legacyPublicEndpoint: { + endpoint: DEPRECATED_DEV_PUBLIC_ENDPOINT, + status: "deprecated", + activeGreenEligible: false + } + } +}; + +assert.deepEqual( + findDeprecatedPublicEndpoints("current public endpoint http://74.48.78.17:6667/health"), + [DEPRECATED_DEV_PUBLIC_ENDPOINT] +); +assert.deepEqual( + findDeprecatedPublicEndpoints("old public :6666 evidence"), + [DEPRECATED_RESERVED_PROD_ENDPOINT] +); +assert.deepEqual( + findDeprecatedPublicEndpoints("http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667 is an internal k3s service URL"), + [] +); +assert.deepEqual( + findDeprecatedPublicEndpoints('"port": 6667, "note": "Internal k3s service port only"'), + [] +); +assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints(activeReport, "current public :6667 endpoint"), + [DEPRECATED_DEV_PUBLIC_ENDPOINT] +); +assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints(activeReportWithLegacyException, "legacy public :6667 endpoint is deprecated"), + [] +); +assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints({ reportLifecycle: { state: "historical" } }, "current public :6667 endpoint"), + [] +); + +console.log("validated report lifecycle endpoint scanner"); diff --git a/scripts/validate-m2-deploy-smoke-active.mjs b/scripts/validate-m2-deploy-smoke-active.mjs index d4ac4dab..a49ce308 100644 --- a/scripts/validate-m2-deploy-smoke-active.mjs +++ b/scripts/validate-m2-deploy-smoke-active.mjs @@ -4,6 +4,11 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + findForbiddenActiveDeprecatedEndpoints, + REPORT_LIFECYCLE_VERSION +} from "../internal/dev-report-lifecycle.mjs"; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const reportPath = path.join(repoRoot, "reports/dev-gate/dev-m2-deploy-smoke-active.json"); @@ -26,10 +31,22 @@ function assertTimestamp(value, label) { } async function main() { - const report = JSON.parse(await readFile(reportPath, "utf8")); + const raw = await readFile(reportPath, "utf8"); + const report = JSON.parse(raw); assertObject(report, "report"); assert.equal(report.issue, "pikasTech/HWLAB#23"); assert.equal(report.taskId, "m2-dev-deploy-smoke"); + assertObject(report.reportLifecycle, "reportLifecycle"); + assert.equal(report.reportLifecycle.version, REPORT_LIFECYCLE_VERSION); + assert.equal(report.reportLifecycle.state, "active"); + assert.equal(report.reportLifecycle.activeEndpoint, "http://74.48.78.17:16667"); + assert.equal(report.reportLifecycle.activeBrowserEndpoint, "http://74.48.78.17:16666"); + assert.equal(report.reportLifecycle.deprecatedEndpoint, null); + assert.deepEqual( + findForbiddenActiveDeprecatedEndpoints(report, raw), + [], + "active M2 report must not present deprecated public ports as current endpoint evidence" + ); assert.equal(report.status, "pass"); assert.equal(report.endpoint, "http://74.48.78.17:16667"); assert.equal(report.frontendEndpoint, "http://74.48.78.17:16666");