This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch 2.0.0 in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit bf2a36868b279781824338dbf4d8d5ac72a06181 Author: Logic <[email protected]> AuthorDate: Fri May 29 02:14:08 2026 +0800 feat(web-next): add release runtime readiness gates --- .github/workflows/frontend-build-test.yml | 7 +- .../docker-compose.yaml | 5 +- .../nginx/default.conf | 20 +++ web-next/Dockerfile | 20 ++- web-next/app/api/[...path]/route.ts | 19 +++ web-next/next.config.mjs | 19 +-- web-next/package.json | 6 +- web-next/scripts/release-budget.mjs | 100 +++++++++++ web-next/scripts/release-budget.test.ts | 60 +++++++ web-next/scripts/release-checklist.mjs | 111 ++++++++++++ web-next/scripts/release-checklist.test.ts | 64 +++++++ web-next/scripts/release-compose.mjs | 85 ++++++++++ web-next/scripts/release-compose.test.ts | 59 +++++++ .../scripts/release-readiness-contract.test.ts | 188 +++++++++++++++++++++ web-next/scripts/session-security-contract.test.ts | 68 ++++++++ 15 files changed, 809 insertions(+), 22 deletions(-) diff --git a/.github/workflows/frontend-build-test.yml b/.github/workflows/frontend-build-test.yml index 8daadcec93..55b57c95de 100644 --- a/.github/workflows/frontend-build-test.yml +++ b/.github/workflows/frontend-build-test.yml @@ -22,14 +22,14 @@ name: Frontend CI on: push: - branches: [ master, dev, action* ] + branches: [ master, dev, 'action*', 'codex/**', 'feature/**', 'release/**' ] paths: - 'web-app/**' - 'web-next/**' - 'web-app/src/assets/i18n/**' - '.github/workflows/frontend-build-test.yml' pull_request: - branches: [ master, dev ] + branches: [ master, dev, 'codex/**', 'feature/**', 'release/**' ] paths: - 'web-app/**' - 'web-next/**' @@ -107,3 +107,6 @@ jobs: - name: verify:full (blocking) working-directory: web-next run: npm run verify:full + - name: parity:smoke:baseline (blocking) + working-directory: web-next + run: npm run parity:smoke:baseline diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml index 5882f9c52e..77adc90084 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml @@ -63,7 +63,7 @@ services: - hertzbeat hertzbeat: - image: apache/hertzbeat:1.8.0 + image: "${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}:${HERTZBEAT_RELEASE_VERSION:-1.8.0}" container_name: compose-hertzbeat hostname: hertzbeat restart: always @@ -88,9 +88,12 @@ services: - hertzbeat web-next: + image: "${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}:${HERTZBEAT_RELEASE_VERSION:-1.8.0}" build: context: ../../.. dockerfile: web-next/Dockerfile + args: + HERTZBEAT_RELEASE_VERSION: ${HERTZBEAT_RELEASE_VERSION:-1.8.0} container_name: compose-hertzbeat-web-next hostname: web-next restart: always diff --git a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf index cb4f3ed834..a506ba6773 100644 --- a/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf +++ b/script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf @@ -12,6 +12,14 @@ server { proxy_pass http://web-next:4200; } + location ^~ /overview { + proxy_pass http://web-next:4200; + } + + location ^~ /entities { + proxy_pass http://web-next:4200; + } + location ^~ /trace/ { proxy_pass http://web-next:4200; } @@ -24,6 +32,18 @@ server { proxy_pass http://web-next:4200; } + location ^~ /alert { + proxy_pass http://web-next:4200; + } + + location ^~ /topology { + proxy_pass http://web-next:4200; + } + + location ^~ /setting { + proxy_pass http://web-next:4200; + } + location ^~ /ingestion/otlp { proxy_pass http://web-next:4200; } diff --git a/web-next/Dockerfile b/web-next/Dockerfile index af87301a8f..997b258830 100644 --- a/web-next/Dockerfile +++ b/web-next/Dockerfile @@ -1,21 +1,31 @@ -FROM node:20-alpine AS deps +FROM node:22-alpine AS deps WORKDIR /app COPY web-next/package.json web-next/package-lock.json ./ RUN npm ci -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_OUTPUT_FILE_TRACING_ROOT=/app COPY --from=deps /app/node_modules ./node_modules COPY web-next ./ RUN npm run build -FROM node:20-alpine AS runner +FROM node:22-alpine AS runner WORKDIR /app +ARG HERTZBEAT_RELEASE_VERSION=dev ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=4200 ENV HOSTNAME=0.0.0.0 -COPY --from=builder /app ./ +LABEL org.opencontainers.image.title="Apache HertzBeat Web Next" +LABEL org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}" +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +RUN chown -R nextjs:nodejs /app +USER nextjs EXPOSE 4200 -CMD ["npx", "next", "start", "-p", "4200", "-H", "0.0.0.0"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD wget -q -O - "http://127.0.0.1:${PORT}/overview" > /dev/null || exit 1 +CMD ["node", "server.js"] diff --git a/web-next/app/api/[...path]/route.ts b/web-next/app/api/[...path]/route.ts new file mode 100644 index 0000000000..50c09daa1e --- /dev/null +++ b/web-next/app/api/[...path]/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from 'next/server'; +import { proxyBackendApiRequest } from '@/lib/session-bff'; + +export const dynamic = 'force-dynamic'; + +type RouteContext = { + params: Promise<{ path?: string[] }>; +}; + +async function proxy(request: NextRequest, context: RouteContext) { + const params = await context.params; + return proxyBackendApiRequest(request, `/${params.path?.join('/') || ''}`); +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; diff --git a/web-next/next.config.mjs b/web-next/next.config.mjs index 92a0fd4d93..ed10597113 100644 --- a/web-next/next.config.mjs +++ b/web-next/next.config.mjs @@ -1,25 +1,18 @@ import path from 'node:path'; +const outputFileTracingRoot = process.env.NEXT_OUTPUT_FILE_TRACING_ROOT + ? path.resolve(process.env.NEXT_OUTPUT_FILE_TRACING_ROOT) + : path.join(process.cwd(), '..'); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, poweredByHeader: false, distDir: process.env.NEXT_DIST_DIR || '.next', - outputFileTracingRoot: path.join(process.cwd(), '..'), + output: 'standalone', + outputFileTracingRoot, eslint: { ignoreDuringBuilds: false - }, - async rewrites() { - const backendOrigin = process.env.BACKEND_ORIGIN; - if (!backendOrigin) { - return []; - } - return [ - { - source: '/api/:path*', - destination: `${backendOrigin}/api/:path*` - } - ]; } }; diff --git a/web-next/package.json b/web-next/package.json index c8f2969d5e..94a7c4a4ae 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -10,9 +10,13 @@ "test": "vitest run", "parity:runtime": "node ./scripts/parity/runtime.mjs", "parity:smoke": "node ./scripts/parity/harness.mjs", + "parity:smoke:baseline": "node ./scripts/parity/harness.mjs --milestone 1", "route:matrix": "node ./scripts/route-matrix.mjs", + "release:budget": "node ./scripts/release-budget.mjs", + "release:compose": "node ./scripts/release-compose.mjs", + "release:checklist": "node ./scripts/release-checklist.mjs", "verify": "npm run lint && npm run test && npm run i18n:report && npm run route:matrix", - "verify:full": "npm run build && npm run verify", + "verify:full": "npm run build && npm run release:budget && npm run release:compose && npm run release:checklist && npm run verify", "i18n:report": "node ./scripts/i18n-report.mjs" }, "dependencies": { diff --git a/web-next/scripts/release-budget.mjs b/web-next/scripts/release-budget.mjs new file mode 100644 index 0000000000..8107eea86a --- /dev/null +++ b/web-next/scripts/release-budget.mjs @@ -0,0 +1,100 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +export const DEFAULT_RELEASE_BUDGET_BYTES = 8 * 1024 * 1024; + +export function parseBudgetBytes(value = process.env.HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES) { + if (value === undefined || value === null || String(value).trim() === '') { + return DEFAULT_RELEASE_BUDGET_BYTES; + } + + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES value: ${value}`); + } + return parsed; +} + +export function formatBudgetBytes(bytes) { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; + } + if (bytes >= 1024) { + return `${(bytes / 1024).toFixed(2)} KiB`; + } + return `${bytes} B`; +} + +function collectFiles(rootDir) { + if (!existsSync(rootDir)) { + return []; + } + + return readdirSync(rootDir, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + return collectFiles(entryPath); + } + if (entry.isFile()) { + return [entryPath]; + } + return []; + }); +} + +export function collectReleaseJsAssets(distDir = process.env.NEXT_DIST_DIR || '.next') { + const staticDir = path.resolve(process.cwd(), distDir, 'static'); + + return collectFiles(staticDir) + .filter(filePath => filePath.endsWith('.js')) + .map(filePath => ({ + path: path.relative(path.resolve(process.cwd(), distDir), filePath).replaceAll(path.sep, '/'), + bytes: statSync(filePath).size + })) + .sort((left, right) => left.path.localeCompare(right.path)); +} + +export function evaluateReleaseBudget(entries, maxBytes = DEFAULT_RELEASE_BUDGET_BYTES) { + const normalizedEntries = entries + .map(entry => ({ + path: String(entry.path || ''), + bytes: Number(entry.bytes || 0) + })) + .filter(entry => entry.path && Number.isFinite(entry.bytes) && entry.bytes >= 0); + const totalBytes = normalizedEntries.reduce((total, entry) => total + entry.bytes, 0); + const largestEntries = [...normalizedEntries].sort((left, right) => right.bytes - left.bytes).slice(0, 5); + + return { + totalBytes, + maxBytes, + passed: totalBytes <= maxBytes, + largestEntries + }; +} + +export function assertReleaseBudget({ distDir = process.env.NEXT_DIST_DIR || '.next', maxBytes = parseBudgetBytes() } = {}) { + const entries = collectReleaseJsAssets(distDir); + if (entries.length === 0) { + throw new Error(`No Next.js release JavaScript assets found under ${distDir}/static. Run npm run build first.`); + } + + const result = evaluateReleaseBudget(entries, maxBytes); + if (!result.passed) { + const largest = result.largestEntries + .map(entry => `${entry.path}=${formatBudgetBytes(entry.bytes)}`) + .join(', '); + throw new Error( + `web-next release JavaScript assets exceed budget: ${formatBudgetBytes(result.totalBytes)} > ` + + `${formatBudgetBytes(result.maxBytes)}. Largest chunks: ${largest}`, + ); + } + return result; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const result = assertReleaseBudget(); + console.log( + `web-next release JavaScript assets: ${formatBudgetBytes(result.totalBytes)} / ${formatBudgetBytes(result.maxBytes)}`, + ); +} diff --git a/web-next/scripts/release-budget.test.ts b/web-next/scripts/release-budget.test.ts new file mode 100644 index 0000000000..5b24cca51a --- /dev/null +++ b/web-next/scripts/release-budget.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + collectReleaseJsAssets, + evaluateReleaseBudget, + formatBudgetBytes, + parseBudgetBytes +} from './release-budget.mjs'; + +describe('release budget gate', () => { + it('collects built Next.js static JavaScript assets without counting non-js files', () => { + const currentWorkingDirectory = process.cwd(); + const tempRoot = mkdtempSync(path.join(tmpdir(), 'hertzbeat-release-budget-')); + try { + const chunksDir = path.join(tempRoot, '.next/static/chunks/app'); + mkdirSync(chunksDir, { recursive: true }); + writeFileSync(path.join(chunksDir, 'overview.js'), 'x'.repeat(13)); + writeFileSync(path.join(chunksDir, 'overview.css'), 'x'.repeat(99)); + + process.chdir(tempRoot); + + expect(collectReleaseJsAssets()).toEqual([ + { + path: 'static/chunks/app/overview.js', + bytes: 13 + } + ]); + } finally { + process.chdir(currentWorkingDirectory); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('evaluates total release chunk size against a configurable budget', () => { + const result = evaluateReleaseBudget( + [ + { path: 'static/chunks/a.js', bytes: 6 }, + { path: 'static/chunks/b.js', bytes: 5 } + ], + 10, + ); + + expect(result).toMatchObject({ + totalBytes: 11, + maxBytes: 10, + passed: false + }); + expect(result.largestEntries.map(entry => entry.path)).toEqual(['static/chunks/a.js', 'static/chunks/b.js']); + }); + + it('parses and formats release budget values for CI diagnostics', () => { + expect(parseBudgetBytes('2048')).toBe(2048); + expect(formatBudgetBytes(2048)).toBe('2.00 KiB'); + expect(() => parseBudgetBytes('0')).toThrow('Invalid HERTZBEAT_WEB_NEXT_BUNDLE_BUDGET_BYTES'); + }); +}); diff --git a/web-next/scripts/release-checklist.mjs b/web-next/scripts/release-checklist.mjs new file mode 100644 index 0000000000..ed1c6a0e2d --- /dev/null +++ b/web-next/scripts/release-checklist.mjs @@ -0,0 +1,111 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const webNextRoot = path.resolve(scriptDir, '..'); +const repoRoot = path.resolve(webNextRoot, '..'); + +export const REQUIRED_WEB_NEXT_INGRESS_PREFIXES = [ + '/_next/', + '/overview', + '/entities', + '/alert', + '/topology', + '/setting' +]; + +export const RELEASE_CHECKLIST_ITEMS = [ + { + key: 'ci-blocking-gates', + label: 'Frontend CI keeps build, budget, compose, checklist, tests, and smoke blocking', + verify: files => + files.packageJson.scripts?.['verify:full'] === + 'npm run build && npm run release:budget && npm run release:compose && npm run release:checklist && npm run verify' && + files.workflow.includes('npm run verify:full') && + files.workflow.includes('npm run parity:smoke:baseline') + }, + { + key: 'standalone-release-image', + label: 'web-next release image is standalone, non-root, healthchecked, and version labeled', + verify: files => + files.nextConfig.includes("output: 'standalone'") && + files.dockerfile.includes('FROM node:22-alpine') && + files.dockerfile.includes('USER nextjs') && + files.dockerfile.includes('HEALTHCHECK') && + files.dockerfile.includes('org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"') + }, + { + key: 'compose-version-convergence', + label: 'Compose release stack shares one overrideable server/web-next release version', + verify: files => + files.compose.includes('${HERTZBEAT_RELEASE_VERSION:-1.8.0}') && + files.compose.includes('${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}') && + files.compose.includes('${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}') + }, + { + key: 'promotion-rollback-config', + label: 'Promotion and rollback tags are validated through docker compose config', + verify: files => + files.packageJson.scripts?.['release:compose'] === 'node ./scripts/release-compose.mjs' && + files.releaseCompose.includes('verifyReleaseComposeConfig') && + files.releaseCompose.includes('HERTZBEAT_ROLLBACK_VERSION') + }, + { + key: 'bundle-budget', + label: 'Production Next build has a JavaScript bundle budget gate', + verify: files => + files.packageJson.scripts?.['release:budget'] === 'node ./scripts/release-budget.mjs' && + files.releaseBudget.includes('DEFAULT_RELEASE_BUDGET_BYTES') && + files.releaseBudget.includes('evaluateReleaseBudget') + }, + { + key: 'release-ingress-route-ownership', + label: 'Gateway routes operator route families to web-next and APIs/fallback to Spring', + verify: files => + REQUIRED_WEB_NEXT_INGRESS_PREFIXES.every(prefix => files.gateway.includes(`location ^~ ${prefix}`)) && + files.gateway.includes('proxy_pass http://web-next:4200;') && + files.gateway.includes('location ^~ /api/') && + files.gateway.includes('location /') && + files.gateway.includes('proxy_pass http://hertzbeat:1157;') + } +]; + +export function readReleaseChecklistFiles(rootDir = repoRoot) { + const readRepoFile = relativePath => readFileSync(path.join(rootDir, relativePath), 'utf8'); + const packageText = readRepoFile('web-next/package.json'); + + return { + packageJson: JSON.parse(packageText), + workflow: readRepoFile('.github/workflows/frontend-build-test.yml'), + dockerfile: readRepoFile('web-next/Dockerfile'), + nextConfig: readRepoFile('web-next/next.config.mjs'), + compose: readRepoFile('script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml'), + gateway: readRepoFile('script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf'), + releaseBudget: readRepoFile('web-next/scripts/release-budget.mjs'), + releaseCompose: readRepoFile('web-next/scripts/release-compose.mjs') + }; +} + +export function evaluateReleaseChecklist(files = readReleaseChecklistFiles()) { + return RELEASE_CHECKLIST_ITEMS.map(item => ({ + key: item.key, + label: item.label, + passed: Boolean(item.verify(files)) + })); +} + +export function verifyReleaseChecklist(files = readReleaseChecklistFiles()) { + const results = evaluateReleaseChecklist(files); + const failures = results.filter(result => !result.passed); + if (failures.length > 0) { + throw new Error(`Release checklist failed: ${failures.map(result => result.key).join(', ')}`); + } + return results; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const results = verifyReleaseChecklist(); + console.log(`release checklist ok: ${results.map(result => result.key).join(', ')}`); +} diff --git a/web-next/scripts/release-checklist.test.ts b/web-next/scripts/release-checklist.test.ts new file mode 100644 index 0000000000..19264688ec --- /dev/null +++ b/web-next/scripts/release-checklist.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + REQUIRED_WEB_NEXT_INGRESS_PREFIXES, + evaluateReleaseChecklist, + verifyReleaseChecklist +} from './release-checklist.mjs'; + +function releaseFiles(overrides = {}) { + const gatewayRoutes = REQUIRED_WEB_NEXT_INGRESS_PREFIXES + .map(prefix => `location ^~ ${prefix} {\n proxy_pass http://web-next:4200;\n}`) + .join('\n'); + + return { + packageJson: { + scripts: { + 'verify:full': + 'npm run build && npm run release:budget && npm run release:compose && npm run release:checklist && npm run verify', + 'release:budget': 'node ./scripts/release-budget.mjs', + 'release:compose': 'node ./scripts/release-compose.mjs' + } + }, + workflow: 'run: npm run verify:full\nrun: npm run parity:smoke:baseline', + dockerfile: + 'FROM node:22-alpine\nUSER nextjs\nHEALTHCHECK CMD test\nLABEL org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"', + nextConfig: "output: 'standalone'", + compose: + '${HERTZBEAT_RELEASE_VERSION:-1.8.0}\n${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}\n${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}', + gateway: `${gatewayRoutes}\nlocation ^~ /api/ {\n proxy_pass http://hertzbeat:1157;\n}\nlocation / {\n proxy_pass http://hertzbeat:1157;\n}`, + releaseBudget: 'DEFAULT_RELEASE_BUDGET_BYTES\nevaluateReleaseBudget', + releaseCompose: 'verifyReleaseComposeConfig\nHERTZBEAT_ROLLBACK_VERSION', + ...overrides + }; +} + +describe('release checklist gate', () => { + it('passes only when every release-readiness item is present', () => { + const results = verifyReleaseChecklist(releaseFiles()); + + expect(results.every(result => result.passed)).toBe(true); + expect(results.map(result => result.key)).toEqual([ + 'ci-blocking-gates', + 'standalone-release-image', + 'compose-version-convergence', + 'promotion-rollback-config', + 'bundle-budget', + 'release-ingress-route-ownership' + ]); + }); + + it('fails with the missing checklist key when release ingress drops a web-next route family', () => { + const files = releaseFiles({ + gateway: + 'location ^~ /_next/ { proxy_pass http://web-next:4200; }\n' + + 'location ^~ /api/ { proxy_pass http://hertzbeat:1157; }\n' + + 'location / { proxy_pass http://hertzbeat:1157; }' + }); + + expect(evaluateReleaseChecklist(files).find(result => result.key === 'release-ingress-route-ownership')?.passed).toBe( + false, + ); + expect(() => verifyReleaseChecklist(files)).toThrow('release-ingress-route-ownership'); + }); +}); diff --git a/web-next/scripts/release-compose.mjs b/web-next/scripts/release-compose.mjs new file mode 100644 index 0000000000..910f57f9aa --- /dev/null +++ b/web-next/scripts/release-compose.mjs @@ -0,0 +1,85 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../..'); +export const NEXT_OBSERVABILITY_COMPOSE = + 'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml'; + +export function readRootReleaseVersion(rootPom = readFileSync(path.join(repoRoot, 'pom.xml'), 'utf8')) { + const version = rootPom.match(/<hzb\.version>\s*([^<]+?)\s*<\/hzb\.version>/)?.[1]?.trim(); + if (!version) { + throw new Error('Unable to resolve <hzb.version> from root pom.xml'); + } + return version; +} + +export function rollbackProbeVersion(releaseVersion) { + return process.env.HERTZBEAT_ROLLBACK_VERSION || `${releaseVersion}-rollback-check`; +} + +export function composeConfigEnv(releaseVersion, extraEnv = process.env) { + return { + ...extraEnv, + HERTZBEAT_RELEASE_VERSION: releaseVersion, + HERTZBEAT_SERVER_IMAGE: extraEnv.HERTZBEAT_SERVER_IMAGE || 'apache/hertzbeat', + HERTZBEAT_WEB_NEXT_IMAGE: extraEnv.HERTZBEAT_WEB_NEXT_IMAGE || 'apache/hertzbeat-web-next' + }; +} + +export function assertResolvedReleaseImages(configText, releaseVersion) { + const expectedServerImage = `image: apache/hertzbeat:${releaseVersion}`; + const expectedWebNextImage = `image: apache/hertzbeat-web-next:${releaseVersion}`; + const expectedBuildArg = `HERTZBEAT_RELEASE_VERSION: ${releaseVersion}`; + + for (const expected of [expectedServerImage, expectedWebNextImage, expectedBuildArg]) { + if (!configText.includes(expected)) { + throw new Error(`docker compose config did not resolve ${expected}`); + } + } +} + +export function runDockerComposeConfig(releaseVersion) { + const composePath = path.join(repoRoot, NEXT_OBSERVABILITY_COMPOSE); + const command = ['compose', '-f', composePath, 'config']; + const result = spawnSync('docker', command, { + cwd: repoRoot, + env: composeConfigEnv(releaseVersion), + encoding: 'utf8' + }); + + if (result.error || result.status !== 0) { + throw new Error( + `docker compose config failed for release ${releaseVersion}: ${ + result.error?.message || result.stderr || `exit ${result.status}` + }`, + ); + } + + return result.stdout; +} + +export function verifyReleaseComposeConfig({ + releaseVersion = process.env.HERTZBEAT_RELEASE_VERSION || readRootReleaseVersion(), + rollbackVersion = rollbackProbeVersion(releaseVersion), + configRunner = runDockerComposeConfig +} = {}) { + const promotionConfig = configRunner(releaseVersion); + assertResolvedReleaseImages(promotionConfig, releaseVersion); + + const rollbackConfig = configRunner(rollbackVersion); + assertResolvedReleaseImages(rollbackConfig, rollbackVersion); + + return { + releaseVersion, + rollbackVersion + }; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const result = verifyReleaseComposeConfig(); + console.log(`docker compose promotion/rollback config ok: ${result.releaseVersion} -> ${result.rollbackVersion}`); +} diff --git a/web-next/scripts/release-compose.test.ts b/web-next/scripts/release-compose.test.ts new file mode 100644 index 0000000000..3ce1bee5ec --- /dev/null +++ b/web-next/scripts/release-compose.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertResolvedReleaseImages, + composeConfigEnv, + readRootReleaseVersion, + rollbackProbeVersion, + verifyReleaseComposeConfig +} from './release-compose.mjs'; + +describe('release compose gate', () => { + it('resolves the root release version from hzb.version', () => { + expect(readRootReleaseVersion('<project><properties><hzb.version>1.8.0</hzb.version></properties></project>')).toBe( + '1.8.0', + ); + }); + + it('requires server image, web-next image, and web-next build arg to share the release version', () => { + expect(() => + assertResolvedReleaseImages( + [ + 'image: apache/hertzbeat:1.8.0', + 'image: apache/hertzbeat-web-next:1.8.0', + 'HERTZBEAT_RELEASE_VERSION: 1.8.0' + ].join('\n'), + '1.8.0', + ), + ).not.toThrow(); + + expect(() => + assertResolvedReleaseImages('image: apache/hertzbeat:1.8.0\nimage: apache/hertzbeat-web-next:old', '1.8.0'), + ).toThrow('docker compose config did not resolve'); + }); + + it('checks promotion and rollback versions through the compose config runner', () => { + const checkedVersions: string[] = []; + + const result = verifyReleaseComposeConfig({ + releaseVersion: '1.8.0', + rollbackVersion: '1.7.9', + configRunner: version => { + checkedVersions.push(version); + return [ + `image: apache/hertzbeat:${version}`, + `image: apache/hertzbeat-web-next:${version}`, + `HERTZBEAT_RELEASE_VERSION: ${version}` + ].join('\n'); + } + }); + + expect(result).toEqual({ releaseVersion: '1.8.0', rollbackVersion: '1.7.9' }); + expect(checkedVersions).toEqual(['1.8.0', '1.7.9']); + }); + + it('builds a deterministic rollback probe version when no rollback override is supplied', () => { + expect(rollbackProbeVersion('1.8.0')).toBe('1.8.0-rollback-check'); + expect(composeConfigEnv('2.0.0', {}).HERTZBEAT_RELEASE_VERSION).toBe('2.0.0'); + }); +}); diff --git a/web-next/scripts/release-readiness-contract.test.ts b/web-next/scripts/release-readiness-contract.test.ts new file mode 100644 index 0000000000..19828c9080 --- /dev/null +++ b/web-next/scripts/release-readiness-contract.test.ts @@ -0,0 +1,188 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const webNextRoot = resolve(__dirname, '..'); +const repoRoot = resolve(webNextRoot, '..'); + +function readWorkflow(): string { + return readFileSync(resolve(repoRoot, '.github/workflows/frontend-build-test.yml'), 'utf8'); +} + +function readDockerfile(): string { + return readFileSync(resolve(webNextRoot, 'Dockerfile'), 'utf8'); +} + +function readNextObservabilityCompose(): string { + return readFileSync( + resolve(repoRoot, 'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/docker-compose.yaml'), + 'utf8', + ); +} + +function readNextConfig(): string { + return readFileSync(resolve(webNextRoot, 'next.config.mjs'), 'utf8'); +} + +function readReleaseBudgetScript(): string { + return readFileSync(resolve(webNextRoot, 'scripts/release-budget.mjs'), 'utf8'); +} + +function readReleaseComposeScript(): string { + return readFileSync(resolve(webNextRoot, 'scripts/release-compose.mjs'), 'utf8'); +} + +function readReleaseChecklistScript(): string { + return readFileSync(resolve(webNextRoot, 'scripts/release-checklist.mjs'), 'utf8'); +} + +function readNextObservabilityGateway(): string { + return readFileSync( + resolve(repoRoot, 'script/docker-compose/hertzbeat-postgresql-victoria-metrics-next-observability/nginx/default.conf'), + 'utf8', + ); +} + +function readRootPom(): string { + return readFileSync(resolve(repoRoot, 'pom.xml'), 'utf8'); +} + +function readPackageJson(): { scripts?: Record<string, string> } { + return JSON.parse(readFileSync(resolve(webNextRoot, 'package.json'), 'utf8')); +} + +function extractBranches(workflow: string, sectionName: 'push' | 'pull_request'): string[] { + const nextSection = sectionName === 'push' ? 'pull_request' : 'jobs'; + const sectionPattern = new RegExp(`\\n\\s+${sectionName}:([\\s\\S]*?)\\n\\s*${nextSection}:`); + const section = workflow.match(sectionPattern)?.[1] ?? ''; + const branches = section.match(/branches:\s*\[([^\]]+)\]/)?.[1] ?? ''; + + return branches + .split(',') + .map((branch) => branch.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); +} + +function extractWorkflowNodeMajors(workflow: string): string[] { + return [...workflow.matchAll(/node-version:\s*['"]?(\d+)['"]?/g)].map(match => match[1]); +} + +function extractDockerNodeMajors(dockerfile: string): string[] { + return [...dockerfile.matchAll(/FROM\s+node:(\d+)-alpine/g)].map(match => match[1]); +} + +function extractXmlTag(xml: string, tagName: string): string { + const tagPattern = new RegExp(`<${tagName}>\\s*([^<]+?)\\s*</${tagName}>`); + const value = xml.match(tagPattern)?.[1]?.trim(); + if (!value) { + throw new Error(`Missing <${tagName}> in root pom.xml`); + } + return value; +} + +describe('release-readiness validation baseline', () => { + it('keeps frontend CI active for release-readiness working branches', () => { + const workflow = readWorkflow(); + + expect(extractBranches(workflow, 'push')).toEqual( + expect.arrayContaining(['master', 'dev', 'codex/**', 'feature/**', 'release/**']), + ); + expect(extractBranches(workflow, 'pull_request')).toEqual( + expect.arrayContaining(['master', 'dev', 'codex/**', 'feature/**', 'release/**']), + ); + }); + + it('keeps milestone-one parity smoke as a blocking validation command', () => { + const workflow = readWorkflow(); + const packageJson = readPackageJson(); + + expect(packageJson.scripts?.['parity:smoke:baseline']).toContain('--milestone 1'); + expect(workflow).toContain('npm run parity:smoke:baseline'); + }); + + it('runs a release bundle budget after the production build in full verification', () => { + const packageJson = readPackageJson(); + const releaseBudgetScript = readReleaseBudgetScript(); + + expect(packageJson.scripts?.['release:budget']).toBe('node ./scripts/release-budget.mjs'); + expect(packageJson.scripts?.['verify:full']).toContain('npm run build && npm run release:budget'); + expect(releaseBudgetScript).toContain('DEFAULT_RELEASE_BUDGET_BYTES'); + expect(releaseBudgetScript).toContain('evaluateReleaseBudget'); + }); + + it('runs a compose promotion and rollback gate in full verification', () => { + const packageJson = readPackageJson(); + const releaseComposeScript = readReleaseComposeScript(); + + expect(packageJson.scripts?.['release:compose']).toBe('node ./scripts/release-compose.mjs'); + expect(packageJson.scripts?.['verify:full']).toContain('npm run release:budget && npm run release:compose'); + expect(releaseComposeScript).toContain('verifyReleaseComposeConfig'); + expect(releaseComposeScript).toContain('HERTZBEAT_ROLLBACK_VERSION'); + expect(releaseComposeScript).toContain('docker compose'); + }); + + it('runs the release checklist gate and keeps release ingress aligned with web-next route families', () => { + const packageJson = readPackageJson(); + const releaseChecklistScript = readReleaseChecklistScript(); + const gateway = readNextObservabilityGateway(); + + expect(packageJson.scripts?.['release:checklist']).toBe('node ./scripts/release-checklist.mjs'); + expect(packageJson.scripts?.['verify:full']).toBe( + 'npm run build && npm run release:budget && npm run release:compose && npm run release:checklist && npm run verify', + ); + expect(releaseChecklistScript).toContain('RELEASE_CHECKLIST_ITEMS'); + expect(releaseChecklistScript).toContain('verifyReleaseChecklist'); + + for (const routePrefix of ['/_next/', '/overview', '/entities', '/alert', '/topology', '/setting']) { + expect(gateway).toContain(`location ^~ ${routePrefix}`); + } + expect(gateway).toContain('proxy_pass http://web-next:4200;'); + expect(gateway).toContain('location ^~ /api/'); + expect(gateway).toContain('proxy_pass http://hertzbeat:1157;'); + }); + + it('keeps the web-next release image aligned with CI Node runtime', () => { + const workflowNodeMajors = new Set(extractWorkflowNodeMajors(readWorkflow())); + const dockerNodeMajors = new Set(extractDockerNodeMajors(readDockerfile())); + + expect(workflowNodeMajors).toContain('22'); + expect(dockerNodeMajors).toEqual(new Set(['22'])); + }); + + it('packages web-next as a standalone non-root healthchecked release image', () => { + const dockerfile = readDockerfile(); + const nextConfig = readNextConfig(); + + expect(nextConfig).toMatch(/output:\s*['"]standalone['"]/); + expect(dockerfile).toContain('COPY --from=builder /app/.next/standalone ./'); + expect(dockerfile).toContain('COPY --from=builder /app/.next/static ./.next/static'); + expect(dockerfile).toContain('COPY --from=builder /app/public ./public'); + expect(dockerfile).toContain('USER nextjs'); + expect(dockerfile).toContain('HEALTHCHECK'); + expect(dockerfile).toContain('CMD ["node", "server.js"]'); + expect(dockerfile).not.toContain('npx'); + }); + + it('uses one overrideable release version for the next observability compose stack', () => { + const releaseVersion = extractXmlTag(readRootPom(), 'hzb.version'); + const compose = readNextObservabilityCompose(); + const releaseVersionExpression = `\${HERTZBEAT_RELEASE_VERSION:-${releaseVersion}}`; + + expect(compose).toContain( + `image: "\${HERTZBEAT_SERVER_IMAGE:-apache/hertzbeat}:${releaseVersionExpression}"`, + ); + expect(compose).toContain( + `image: "\${HERTZBEAT_WEB_NEXT_IMAGE:-apache/hertzbeat-web-next}:${releaseVersionExpression}"`, + ); + expect(compose).toContain(`HERTZBEAT_RELEASE_VERSION: ${releaseVersionExpression}`); + expect(compose).not.toMatch(/image:\s+apache\/hertzbeat:\d/); + }); + + it('labels the web-next release image with the compose release version', () => { + const dockerfile = readDockerfile(); + + expect(dockerfile).toContain('ARG HERTZBEAT_RELEASE_VERSION=dev'); + expect(dockerfile).toContain('org.opencontainers.image.version="${HERTZBEAT_RELEASE_VERSION}"'); + }); +}); diff --git a/web-next/scripts/session-security-contract.test.ts b/web-next/scripts/session-security-contract.test.ts new file mode 100644 index 0000000000..5a712f77b9 --- /dev/null +++ b/web-next/scripts/session-security-contract.test.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const WEB_NEXT_ROOT = resolve(__dirname, '..'); + +function readWebNext(path: string) { + return readFileSync(resolve(WEB_NEXT_ROOT, path), 'utf8'); +} + +function existsWebNext(path: string) { + return existsSync(resolve(WEB_NEXT_ROOT, path)); +} + +describe('session security and token boundary contract', () => { + it('keeps ui session tokens behind same-origin BFF cookies instead of browser localStorage', () => { + const apiClientSource = readWebNext('lib/api-client.ts'); + const loginFormSource = readWebNext('components/pages/login-form.tsx'); + const authGateSource = readWebNext('components/shell/auth-gate.tsx'); + const appFrameSource = readWebNext('components/shell/app-frame.tsx'); + const monitorManageSource = readWebNext('app/monitors/monitor-manage-page.tsx'); + + expect(apiClientSource).toContain("credentials: 'same-origin'"); + expect(apiClientSource).not.toContain("localStorage.getItem('Authorization')"); + expect(apiClientSource).not.toContain("localStorage.getItem('refresh-token')"); + expect(apiClientSource).not.toContain("Authorization: `Bearer ${token}`"); + + expect(loginFormSource).toContain('assertSessionLoginSuccess'); + expect(loginFormSource).not.toContain('persistLoginTokens(window.localStorage'); + + expect(authGateSource).toContain("readClientSessionState()"); + expect(authGateSource).not.toContain("localStorage.getItem('Authorization')"); + + expect(appFrameSource).toContain("clearClientSession()"); + expect(appFrameSource).not.toContain("localStorage.removeItem('Authorization')"); + expect(appFrameSource).not.toContain("localStorage.removeItem('refresh-token')"); + + expect(monitorManageSource).not.toContain("Authorization: `Bearer ${token}`"); + expect(monitorManageSource).not.toContain('getAuthorizationToken'); + + [ + 'app/api/[...path]/route.ts', + 'app/api/account/auth/form/route.ts', + 'app/api/account/auth/refresh/route.ts', + 'app/api/account/session/route.ts', + 'lib/session-bff.ts', + 'lib/session-client.ts' + ].forEach(path => { + expect(existsWebNext(path), `${path} should exist`).toBe(true); + }); + + const sessionBffSource = readWebNext('lib/session-bff.ts'); + const loginRouteSource = readWebNext('app/api/account/auth/form/route.ts'); + const proxyRouteSource = readWebNext('app/api/[...path]/route.ts'); + const nextConfigSource = readWebNext('next.config.mjs'); + + expect(sessionBffSource).toContain('httpOnly: true'); + expect(sessionBffSource).toContain("sameSite: 'lax'"); + expect(sessionBffSource).toContain('HB_UI_ACCESS_COOKIE'); + expect(sessionBffSource).toContain('HB_UI_REFRESH_COOKIE'); + expect(sessionBffSource).toContain('HB_UI_SESSION_MARKER_COOKIE'); + expect(loginRouteSource).toContain('applySessionCookies'); + expect(loginRouteSource).toContain('sanitizeSessionPayload'); + expect(proxyRouteSource).toContain('proxyBackendApiRequest'); + expect(nextConfigSource).not.toContain("source: '/api/:path*'"); + expect(nextConfigSource).not.toContain('destination: `${backendOrigin}/api/:path*`'); + }); +}); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
