Files
memind/scripts/verify-canary-promotion-evidence.mjs

241 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { hashArtifact } from '../release-gate/artifact.mjs';
import {
diffTouchesRuntimePaths,
isReleaseOnlyPath,
} from '../release-gate/incremental.mjs';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const REMOTE_ROOT = process.env.STUDIO_REMOTE_ROOT ?? '/Users/john/Project';
const CANDIDATE_BASE = `${REMOTE_ROOT}/Memind-candidates`;
const REMOTE_SCRIPT = [
'set -euo pipefail',
'expected_commit="$1"',
'candidate_base="$2"',
'stable_manifest="/Users/john/Project/Memind/.release-manifest.txt"',
'matched=""',
'latest=""',
'for dir in "${candidate_base}"/*; do',
' [[ -d "${dir}" ]] || continue',
' [[ -f "${dir}/.release-manifest.txt" ]] || continue',
' if [[ -z "${latest}" || "$(basename "${dir}")" > "$(basename "${latest}")" ]]; then',
' latest="${dir}"',
' fi',
' if grep -q "^git_head=${expected_commit}$" "${dir}/.release-manifest.txt"; then',
' matched="${dir}"',
' fi',
'done',
'if [[ -n "${matched}" ]]; then candidate="${matched}"; else candidate="${latest}"; fi',
'[[ -n "${candidate}" ]] || { echo "NO_CANDIDATE"; exit 1; }',
'printf \'candidate_dir=%s\\n\' "${candidate}"',
'cat "${candidate}/.release-manifest.txt"',
'if [[ -f "${stable_manifest}" ]]; then',
' printf \'\\n---STABLE---\\n\'',
' cat "${stable_manifest}"',
'fi',
'printf \'\\n---HEALTH---\\n\'',
'curl -s -D - -o /dev/null http://127.0.0.1:18081/api/status 2>/dev/null | tr -d \'\\r\' || true',
'printf \'\\n---STABLE-HEALTH---\\n\'',
'curl -s -D - -o /dev/null http://127.0.0.1:8081/api/status 2>/dev/null | tr -d \'\\r\' || true',
'',
].join('\n');
function parseArgs(argv) {
const options = {
host: process.env.STUDIO_HOST ?? '58.38.22.103',
commit: null,
artifact: path.join(ROOT, '.runtime', 'portal'),
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--host') options.host = argv[++index] ?? '';
else if (arg === '--commit') options.commit = argv[++index] ?? '';
else if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? '');
else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function git(...args) {
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
}
function ssh(host, args, input = '') {
const result = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, ...args], {
encoding: 'utf8',
input,
});
if (result.status !== 0) {
throw new Error(`SSH to ${host} failed:\n${result.stderr || result.stdout}`);
}
return result.stdout.trim();
}
function parseManifest(content) {
const fields = {};
for (const line of content.split('\n')) {
const separator = line.indexOf('=');
if (separator <= 0) continue;
fields[line.slice(0, separator)] = line.slice(separator + 1);
}
return fields;
}
function listReleaseOnlyDiff(baseCommit, headCommit) {
const result = spawnSync(
'git',
['diff', '--name-only', `${baseCommit}..${headCommit}`],
{ cwd: ROOT, encoding: 'utf8' },
);
if (result.status !== 0) {
throw new Error(`git diff failed: ${result.stderr || result.stdout}`);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function assertArtifactIdenticalPromotion({
candidateCommit,
expectedCommit,
}) {
const changedPaths = listReleaseOnlyDiff(candidateCommit, expectedCommit);
if (changedPaths.length === 0) {
return { changedPaths, promotion: 'artifact_identical_no_diff' };
}
if (diffTouchesRuntimePaths(changedPaths)) {
throw new Error(
`Canary candidate commit mismatch and diff is not release-only: candidate=${candidateCommit} expected=${expectedCommit} changed=${changedPaths.join(',')}`,
);
}
if (!changedPaths.every(isReleaseOnlyPath)) {
throw new Error(
`Canary promotion diff contains non-release paths: ${changedPaths.join(',')}`,
);
}
return { changedPaths, promotion: 'artifact_identical_release_only_diff' };
}
function parseRemoteSections(remoteOutput) {
const sections = {
manifestText: remoteOutput,
stableText: '',
healthText: '',
stableHealthText: '',
};
const stableMarker = '\n---STABLE---\n';
const healthMarker = '\n---HEALTH---\n';
const stableHealthMarker = '\n---STABLE-HEALTH---\n';
const stableIndex = remoteOutput.indexOf('---STABLE---');
const healthIndex = remoteOutput.indexOf('---HEALTH---');
const stableHealthIndex = remoteOutput.indexOf('---STABLE-HEALTH---');
if (healthIndex < 0) {
throw new Error('Canary promotion evidence response missing health probe');
}
sections.manifestText = remoteOutput.slice(
0,
stableIndex >= 0 ? stableIndex : healthIndex,
);
if (stableIndex >= 0 && healthIndex > stableIndex) {
sections.stableText = remoteOutput.slice(
stableIndex + stableMarker.trim().length,
healthIndex,
);
}
sections.healthText = remoteOutput.slice(
healthIndex + healthMarker.trim().length,
stableHealthIndex >= 0 ? stableHealthIndex : remoteOutput.length,
);
if (stableHealthIndex >= 0) {
sections.stableHealthText = remoteOutput.slice(
stableHealthIndex + stableHealthMarker.trim().length,
);
}
return sections;
}
try {
const options = parseArgs(process.argv);
const commit = options.commit ?? git('rev-parse', 'HEAD');
const remoteUserHost = options.host.includes('@') ? options.host : `john@${options.host}`;
const localArtifact = await hashArtifact(options.artifact);
const remoteOutput = ssh(
remoteUserHost,
['/bin/bash', '-s', '--', commit, CANDIDATE_BASE],
REMOTE_SCRIPT,
);
const sections = parseRemoteSections(remoteOutput);
const manifestText = sections.manifestText;
const healthText = sections.healthText;
const stableHealthText = sections.stableHealthText;
const stableManifest = parseManifest(sections.stableText);
const candidateDir = manifestText
.split('\n')
.find((line) => line.startsWith('candidate_dir='))
?.slice('candidate_dir='.length);
const manifest = parseManifest(
manifestText
.split('\n')
.filter((line) => !line.startsWith('candidate_dir='))
.join('\n'),
);
if (!candidateDir) {
throw new Error('103 has no canary candidate directory');
}
let promotionMode = 'commit_match';
if (manifest.git_head !== commit) {
const promotion = assertArtifactIdenticalPromotion({
candidateCommit: manifest.git_head,
expectedCommit: commit,
});
promotionMode = promotion.promotion;
}
const candidateHealthy = /^HTTP\/1\.1 200/m.test(healthText)
&& /^x-memind-runtime-role: candidate/im.test(healthText);
const stableHealthy = /^HTTP\/1\.1 200/m.test(stableHealthText);
if (candidateHealthy) {
promotionMode = `${promotionMode}_candidate_healthy`;
} else if (
promotionMode.startsWith('artifact_identical')
&& stableHealthy
&& stableManifest.git_head === manifest.git_head
) {
promotionMode = `${promotionMode}_stable_fallback`;
} else if (!candidateHealthy) {
throw new Error('Canary candidate /api/status is not healthy on 18081');
}
if (candidateHealthy && !/^x-memind-runtime-role: candidate/im.test(healthText)) {
throw new Error('Canary candidate health probe missing X-Memind-Runtime-Role: candidate');
}
console.log('Canary promotion evidence verified');
console.log(`candidate_dir=${candidateDir}`);
console.log(`release_id=${manifest.release_id ?? 'unknown'}`);
console.log(`git_head=${manifest.git_head}`);
console.log(`promotion_mode=${promotionMode}`);
console.log(`artifact_sha256=${localArtifact.sha256}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}