feat(ql3): add reviewed upgrade staging

This commit is contained in:
whyour
2026-08-30 18:14:30 +08:00
parent 157b1d9afd
commit 7a8acacb6c
14 changed files with 437 additions and 22 deletions
+1 -1
View File
@@ -240,7 +240,7 @@ function validateMilestoneRecord(record, product, sourceRevision, variant) {
: 'cluster_integration_candidate_not_public_release';
const expectedSchema =
product === 'local'
? 'qinglong/alpha-local-milestone@v3'
? 'qinglong/alpha-local-milestone@v4'
: 'qinglong/alpha-cluster-milestone@v1';
if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
+12 -5
View File
@@ -12,7 +12,7 @@ const {
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-milestone@v3';
const SCHEMA = 'qinglong/alpha-local-milestone@v4';
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({
readme: 'README.md',
@@ -220,6 +220,7 @@ function bundleRecord(options, architecture) {
operatorImageId: report.operatorImageId,
verificationSha256: report.verificationSha256,
upgradeReadinessSha256: report.upgradeReadinessSha256,
upgradeRehearsalSha256: report.upgradeRehearsalSha256,
});
}
@@ -234,6 +235,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'operatorImageId',
'verificationSha256',
'upgradeReadinessSha256',
'upgradeRehearsalSha256',
]) ||
record.artifactName !==
artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
@@ -248,6 +250,7 @@ function validateArtifactRecord(record, architecture, manifest) {
!SHA256_PATTERN.test(record.operatorImageId || '') ||
!SHA256_PATTERN.test(record.verificationSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeReadinessSha256 || '') ||
!SHA256_PATTERN.test(record.upgradeRehearsalSha256 || '') ||
record.applicationImageId === record.operatorImageId
) {
fail(`${architecture} milestone artifact record is incompatible`);
@@ -316,7 +319,7 @@ function auditLocalAlphaMilestone(options) {
'artifacts',
'readme',
]) ||
manifest.schemaVersion !== 3 ||
manifest.schemaVersion !== 4 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -361,6 +364,8 @@ function auditLocalAlphaMilestone(options) {
new Set(records.map((record) => record.verificationSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeReadinessSha256)).size !==
ARCHITECTURES.length ||
new Set(records.map((record) => record.upgradeRehearsalSha256)).size !==
ARCHITECTURES.length
) {
fail('milestone architecture subjects are not distinct');
@@ -388,7 +393,7 @@ function auditLocalAlphaMilestone(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v3',
schema: 'qinglong/alpha-local-milestone-audit@v4',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
variant: manifest.variant,
@@ -427,7 +432,9 @@ function finalizeLocalAlphaMilestone(options) {
artifacts.amd64.archiveSha256 === artifacts.arm64.archiveSha256 ||
artifacts.amd64.verificationSha256 === artifacts.arm64.verificationSha256 ||
artifacts.amd64.upgradeReadinessSha256 ===
artifacts.arm64.upgradeReadinessSha256
artifacts.arm64.upgradeReadinessSha256 ||
artifacts.amd64.upgradeRehearsalSha256 ===
artifacts.arm64.upgradeRehearsalSha256
) {
fail('milestone architecture subjects must be distinct');
}
@@ -440,7 +447,7 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme),
);
const manifest = {
schemaVersion: 3,
schemaVersion: 4,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
+69 -5
View File
@@ -10,8 +10,8 @@ const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-trial-kit@v6';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v4';
const SCHEMA = 'qinglong/alpha-local-trial-kit@v7';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v5';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
@@ -20,6 +20,10 @@ const UPGRADE_READINESS_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-readiness.sh',
);
const UPGRADE_REHEARSAL_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-rehearsal.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024;
@@ -27,6 +31,7 @@ const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024;
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
const MAX_UPGRADE_REHEARSAL_BYTES = 256 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({
@@ -35,6 +40,7 @@ const FILES = Object.freeze({
verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh',
upgradeReadiness: 'upgrade-readiness.sh',
upgradeRehearsal: 'upgrade-rehearsal.sh',
readme: 'README.md',
manifest: 'manifest.json',
checksums: 'SHA256SUMS',
@@ -51,6 +57,7 @@ const VERIFICATION = Object.freeze({
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
legacyUpgradeReadiness: 'passed',
legacyUpgradeStage: 'passed',
});
function verificationGates(variant) {
@@ -480,6 +487,32 @@ function renderUpgradeReadiness(identity) {
return rendered;
}
function renderUpgradeRehearsal(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_REHEARSAL_TEMPLATE,
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade rehearsal template contains an unresolved token');
}
return rendered;
}
function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath);
@@ -660,8 +693,13 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
renderUpgradeReadiness(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeRehearsal),
renderUpgradeRehearsal(manifestIdentity),
0o700,
);
const manifest = {
schemaVersion: 7,
schemaVersion: 8,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
@@ -680,6 +718,10 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
normalized.outputRoot,
FILES.upgradeReadiness,
),
upgradeRehearsal: fileRecord(
normalized.outputRoot,
FILES.upgradeRehearsal,
),
readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord(
normalized.outputRoot,
@@ -697,6 +739,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.readme,
FILES.manifest,
];
@@ -767,10 +810,11 @@ function auditLocalAlphaTrialKit(options) {
'sboms',
'quickstart',
'upgradeReadiness',
'upgradeRehearsal',
'readme',
'verification',
]) ||
manifest.schemaVersion !== 7 ||
manifest.schemaVersion !== 8 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -834,6 +878,23 @@ function auditLocalAlphaTrialKit(options) {
if (actualUpgradeReadiness !== expectedUpgradeReadiness) {
fail('upgrade readiness differs from the canonical inspection journey');
}
validateFileRecord(
manifest.upgradeRehearsal,
FILES.upgradeRehearsal,
bundleRoot,
);
const expectedUpgradeRehearsal = renderUpgradeRehearsal(manifest);
const actualUpgradeRehearsal = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeRehearsal),
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal',
),
'utf8',
);
if (actualUpgradeRehearsal !== expectedUpgradeRehearsal) {
fail('upgrade rehearsal differs from the canonical staging journey');
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom(
readBoundedJson(
@@ -871,6 +932,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
expectedArchive,
].sort();
const actualFiles = fs
@@ -892,6 +954,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.readme,
FILES.manifest,
];
@@ -905,7 +968,7 @@ function auditLocalAlphaTrialKit(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v3',
schema: 'qinglong/alpha-local-trial-kit-audit@v4',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
architecture: manifest.architecture,
@@ -915,6 +978,7 @@ function auditLocalAlphaTrialKit(options) {
operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256,
upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256,
verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
@@ -0,0 +1,166 @@
#!/bin/sh
set -eu
OPERATOR_IMAGE='@@OPERATOR_IMAGE@@'
OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@'
fail() {
printf '%s\n' "QingLong Local Alpha upgrade rehearsal failed: $*" >&2
exit 1
}
usage() {
printf '%s\n' \
'usage: sh upgrade-rehearsal.sh edge|standalone /absolute/legacy-data-root /absolute/new/rehearsal-root <reviewed-sqlite-plan-digest> <reviewed-data-directory-plan-digest>' >&2
exit 2
}
safe_absolute_path() {
case "$1" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
return 1
;;
/*) return 0 ;;
*) return 1 ;;
esac
}
valid_digest() {
[ "${#1}" -eq 64 ] || return 1
case "$1" in
*[!0-9a-f]*) return 1 ;;
*) return 0 ;;
esac
}
extract_digest() {
result_file=$1
field=$2
digest=$(sed -n "s/^.*\"$field\":\"\([0-9a-f][0-9a-f]*\)\".*$/\1/p" "$result_file")
valid_digest "$digest" || fail "$field is missing or invalid in $result_file"
printf '%s' "$digest"
}
[ "$#" -eq 5 ] || usage
profile=$1
legacy_root=$2
rehearsal_root=$3
sqlite_plan_digest=$4
directory_plan_digest=$5
case "$profile" in
edge|standalone) ;;
*) usage ;;
esac
safe_absolute_path "$legacy_root" || fail 'legacy data root is not a safe canonical absolute path'
safe_absolute_path "$rehearsal_root" || fail 'rehearsal root is not a safe canonical absolute path'
valid_digest "$sqlite_plan_digest" || fail 'reviewed SQLite plan digest is invalid'
valid_digest "$directory_plan_digest" || fail 'reviewed data-directory plan digest is invalid'
[ "$legacy_root" != "$rehearsal_root" ] || fail 'legacy and rehearsal roots must be distinct'
case "$rehearsal_root/" in
"$legacy_root"/*) fail 'rehearsal root must not be inside the legacy data root' ;;
esac
case "$legacy_root/" in
"$rehearsal_root"/*) fail 'legacy data root must not be inside the rehearsal root' ;;
esac
[ -d "$legacy_root" ] || fail 'legacy data root does not exist'
[ -f "$legacy_root/db/database.sqlite" ] || fail 'legacy db/database.sqlite does not exist'
legacy_real=$(CDPATH= cd -- "$legacy_root" && pwd -P)
[ "$legacy_real" = "$legacy_root" ] || fail 'legacy data root is not canonical'
[ ! -e "$rehearsal_root" ] || fail 'rehearsal root must not already exist'
rehearsal_parent=${rehearsal_root%/*}
[ -n "$rehearsal_parent" ] || rehearsal_parent=/
[ -d "$rehearsal_parent" ] || fail 'rehearsal root parent does not exist'
rehearsal_parent_real=$(CDPATH= cd -- "$rehearsal_parent" && pwd -P)
[ "$rehearsal_parent_real/${rehearsal_root##*/}" = "$rehearsal_root" ] || fail 'rehearsal root parent is not canonical'
command -v docker >/dev/null 2>&1 || fail 'docker is required'
command -v sha256sum >/dev/null 2>&1 || fail 'sha256sum is required'
command -v grep >/dev/null 2>&1 || fail 'grep is required'
command -v sed >/dev/null 2>&1 || fail 'sed is required'
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
(CDPATH= cd -- "$script_dir" && sha256sum --check SHA256SUMS)
docker info >/dev/null 2>&1 || fail 'docker daemon is unavailable'
docker load --input "$script_dir/$ARCHIVE" >/dev/null
operator_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "io.qinglong.lifecycle"}}|{{index .Config.Labels "io.qinglong.network"}}' "$OPERATOR_IMAGE")
[ "$operator_identity" = "$OPERATOR_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION|short-lived|none-by-default" ] || fail 'operator image identity is incompatible'
old_umask=$(umask)
umask 077
mkdir -m 0700 "$rehearsal_root"
for directory in commands results sqlite data-directory; do
mkdir -m 0700 "$rehearsal_root/$directory"
done
cat >"$rehearsal_root/commands/sqlite-stage.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.stage","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","expectedPlanDigest":"$sqlite_plan_digest"}}
EOF
cat >"$rehearsal_root/commands/sqlite-verify.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.adoption.verify","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json"}}
EOF
chmod 0600 "$rehearsal_root/commands/sqlite-stage.json" "$rehearsal_root/commands/sqlite-verify.json"
uid=$(id -u)
gid=$(id -g)
run_adoption() {
command_file=$1
result_file=$2
docker run --rm --read-only --user "$uid:$gid" --network none \
--cap-drop ALL --security-opt no-new-privileges \
--memory 128m --memory-swap 128m --cpus 0.5 --pids-limit 32 \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \
--mount "type=bind,src=$legacy_root,dst=/var/lib/qinglong2,readonly" \
--mount "type=bind,src=$rehearsal_root,dst=/var/lib/qinglong3" \
"$OPERATOR_IMAGE" adoption run \
--command-file "/var/lib/qinglong3/commands/$command_file" \
>"$rehearsal_root/results/$result_file"
}
run_adoption sqlite-stage.json sqlite-stage.result.json
grep -q '"status":"staged"' "$rehearsal_root/results/sqlite-stage.result.json" || fail 'SQLite stage did not report staged'
run_adoption sqlite-verify.json sqlite-verify.result.json
grep -q '"status":"verified"' "$rehearsal_root/results/sqlite-verify.result.json" || fail 'SQLite verify did not report verified'
sqlite_manifest_digest=$(extract_digest "$rehearsal_root/results/sqlite-verify.result.json" manifestDigest)
cat >"$rehearsal_root/commands/sqlite-activation.json" <<EOF
{"schemaVersion":1,"operation":"local-sqlite.activation.prepare","options":{"deploymentRoot":"/var/lib/qinglong3","profile":"$profile","sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedManifestDigest":"$sqlite_manifest_digest"}}
EOF
chmod 0600 "$rehearsal_root/commands/sqlite-activation.json"
run_adoption sqlite-activation.json sqlite-activation.result.json
grep -q '"status":"prepared"' "$rehearsal_root/results/sqlite-activation.result.json" || fail 'SQLite activation did not report prepared'
activation_digest=$(extract_digest "$rehearsal_root/results/sqlite-activation.result.json" activationDigest)
cat >"$rehearsal_root/commands/data-directory-stage.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.stage","options":{"deploymentRoot":"/var/lib/qinglong3","dataRoot":"/var/lib/qinglong2","stagingRoot":"/var/lib/qinglong3/data-directory/staged","profile":"$profile","expectedPlanDigest":"$directory_plan_digest","sqlite":{"sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedActivationDigest":"$activation_digest"}}}
EOF
chmod 0600 "$rehearsal_root/commands/data-directory-stage.json"
run_adoption data-directory-stage.json data-directory-stage.result.json
grep -q '"status":"staged"' "$rehearsal_root/results/data-directory-stage.result.json" || fail 'data-directory stage did not report staged'
directory_manifest_digest=$(extract_digest "$rehearsal_root/results/data-directory-stage.result.json" manifestDigest)
cat >"$rehearsal_root/commands/data-directory-verify.json" <<EOF
{"schemaVersion":1,"operation":"local-data-directory.adoption.verify","options":{"deploymentRoot":"/var/lib/qinglong3","dataRoot":"/var/lib/qinglong2","stagingRoot":"/var/lib/qinglong3/data-directory/staged","profile":"$profile","expectedManifestDigest":"$directory_manifest_digest","sqlite":{"sourcePath":"/var/lib/qinglong2/db/database.sqlite","targetPath":"/var/lib/qinglong3/sqlite/qinglong3.sqlite","recoveryPath":"/var/lib/qinglong3/sqlite/database.pre-ql3.sqlite","manifestPath":"/var/lib/qinglong3/sqlite/qinglong3-adoption.json","activationPath":"/var/lib/qinglong3/sqlite/qinglong3-activation.json","expectedActivationDigest":"$activation_digest"}}}
EOF
chmod 0600 "$rehearsal_root/commands/data-directory-verify.json"
run_adoption data-directory-verify.json data-directory-verify.result.json
grep -q '"status":"verified"' "$rehearsal_root/results/data-directory-verify.result.json" || fail 'data-directory verify did not report verified'
cat >"$rehearsal_root/stage-summary.json" <<EOF
{"schemaVersion":1,"schema":"qinglong/local-alpha-upgrade-stage-summary@v1","status":"verified","profile":"$profile","sourceRevision":"$SOURCE_REVISION","architecture":"$ARCHITECTURE","reviewedPlans":{"sqlite":"$sqlite_plan_digest","dataDirectory":"$directory_plan_digest"},"sqlite":{"manifestDigest":"$sqlite_manifest_digest","activationDigest":"$activation_digest"},"dataDirectory":{"manifestDigest":"$directory_manifest_digest"},"legacySource":"read_only","cutover":"not_authorized"}
EOF
chmod 0600 "$rehearsal_root/stage-summary.json"
umask "$old_umask"
printf '%s\n' \
"QingLong 2.x side-by-side upgrade stage completed ($profile, $ARCHITECTURE)." \
"Stage summary: $rehearsal_root/stage-summary.json" \
"SQLite activation evidence: $rehearsal_root/sqlite/qinglong3-activation.json" \
"Data-directory manifest: $rehearsal_root/data-directory/staged/manifest.json" \
'The legacy root remained read-only. No transform/apply, target start, cutover or Legacy rollback was authorized.' \
'Preserve the complete rehearsal root. Do not edit or reuse it as a production data root.'