feat(ql3): ship runnable local alpha quickstart

This commit is contained in:
whyour
2026-08-28 03:56:54 +08:00
parent 82d74cf56f
commit b7a8104d9f
10 changed files with 540 additions and 15 deletions
+68 -6
View File
@@ -10,18 +10,24 @@ 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@v2';
const SCHEMA = 'qinglong/alpha-local-trial-kit@v3';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({
applicationSbom: 'qinglong3-local-application.cdx.json',
operatorSbom: 'qinglong3-local-operator.cdx.json',
verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh',
readme: 'README.md',
manifest: 'manifest.json',
checksums: 'SHA256SUMS',
@@ -148,9 +154,7 @@ function saveDockerImages(images, archivePath) {
function validateImageReference(value, label) {
if (
typeof value !== 'string' ||
value.length < 3 ||
value.length > 256 ||
/[\s\0]/u.test(value)
!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/u.test(value)
) {
fail(`${label} image reference is invalid`);
}
@@ -383,6 +387,34 @@ function archiveName(architecture) {
return `qinglong3-local-trial-kit-${architecture}.docker.tar`;
}
function renderQuickstart(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
QUICKSTART_TEMPLATE,
MAX_QUICKSTART_BYTES,
'quickstart template',
),
'utf8',
);
const replacements = Object.freeze({
'@@APPLICATION_IMAGE@@': identity.images.application.reference,
'@@APPLICATION_ID@@': identity.images.application.id,
'@@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('quickstart template contains an unresolved token');
}
return rendered;
}
function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath);
@@ -541,8 +573,19 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
normalized.readme,
path.join(normalized.outputRoot, FILES.readme),
);
const manifestIdentity = {
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
archive: { file: archive },
images: { application, operator },
};
writeExclusive(
path.join(normalized.outputRoot, FILES.quickstart),
renderQuickstart(manifestIdentity),
0o700,
);
const manifest = {
schemaVersion: 3,
schemaVersion: 4,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
@@ -555,6 +598,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
application: fileRecord(normalized.outputRoot, FILES.applicationSbom),
operator: fileRecord(normalized.outputRoot, FILES.operatorSbom),
},
quickstart: fileRecord(normalized.outputRoot, FILES.quickstart),
readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord(
normalized.outputRoot,
@@ -570,6 +614,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.readme,
FILES.manifest,
];
@@ -637,10 +682,11 @@ function auditLocalAlphaTrialKit(options) {
'archive',
'images',
'sboms',
'quickstart',
'readme',
'verification',
]) ||
manifest.schemaVersion !== 3 ||
manifest.schemaVersion !== 4 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
@@ -673,6 +719,19 @@ function auditLocalAlphaTrialKit(options) {
FILES.verificationEvidence,
bundleRoot,
);
validateFileRecord(manifest.quickstart, FILES.quickstart, bundleRoot);
const expectedQuickstart = renderQuickstart(manifest);
const actualQuickstart = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.quickstart),
MAX_QUICKSTART_BYTES,
'quickstart',
),
'utf8',
);
if (actualQuickstart !== expectedQuickstart) {
fail('quickstart differs from the canonical deployment journey');
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom(
readBoundedJson(
@@ -707,6 +766,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
expectedArchive,
].sort();
const actualFiles = fs
@@ -726,6 +786,7 @@ function auditLocalAlphaTrialKit(options) {
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.readme,
FILES.manifest,
];
@@ -746,6 +807,7 @@ function auditLocalAlphaTrialKit(options) {
archiveSha256: manifest.archive.sha256,
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256,
verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
@@ -199,6 +199,9 @@ function auditWorkflow(contents, findings) {
'--mode=record-verification',
'--mode=create',
'--mode=audit',
'/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"',
'docker stop --time 30 "${QUICKSTART_CONTAINER}"',
'test -s "${QUICKSTART_ROOT}/qinglong3.sqlite"',
'--application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json"',
'--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"',
'--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"',
@@ -222,6 +225,7 @@ function auditWorkflow(contents, findings) {
'--mode=record-verification',
'--mode=create',
'--mode=audit',
'/quickstart.sh"',
'name: Upload the tested native Local Alpha trial kit',
]) {
const index = contents.indexOf(value, cursor + 1);
@@ -0,0 +1,162 @@
#!/bin/sh
set -eu
APPLICATION_IMAGE='@@APPLICATION_IMAGE@@'
APPLICATION_ID='@@APPLICATION_ID@@'
OPERATOR_IMAGE='@@OPERATOR_IMAGE@@'
OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@'
fail() {
printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2
exit 1
}
usage() {
printf '%s\n' \
'usage: sh quickstart.sh edge|standalone /absolute/new/data-root [container-name]' >&2
exit 2
}
[ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage
profile=$1
data_root=$2
container_name=${3:-ql3-alpha-local}
case "$profile" in
edge)
memory=128m
pids=64
;;
standalone)
memory=256m
pids=256
;;
*) usage ;;
esac
case "$data_root" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
fail 'data root must be a canonical absolute path using A-Z, a-z, 0-9, _, ., / or -'
;;
/*) ;;
*) fail 'data root must be absolute' ;;
esac
case "$container_name" in
''|[_.-]*|*[!A-Za-z0-9_.-]*) fail 'container name is invalid' ;;
esac
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'
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
parent=${data_root%/*}
[ -n "$parent" ] || parent=/
[ -d "$parent" ] || fail 'data root parent does not exist'
parent_real=$(CDPATH= cd -- "$parent" && pwd -P)
[ "$parent_real/${data_root##*/}" = "$data_root" ] || fail 'data root parent is not canonical'
[ ! -e "$data_root" ] || fail 'data root must not already exist'
(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
application_identity=$(docker image inspect --format '{{.Id}}|{{.Architecture}}|{{.Config.User}}|{{index .Config.Labels "org.opencontainers.image.revision"}}' "$APPLICATION_IMAGE")
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")
[ "$application_identity" = "$APPLICATION_ID|$ARCHITECTURE|65532:65532|$SOURCE_REVISION" ] || fail 'application image identity is incompatible'
[ "$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 "$data_root"
for directory in owner-peppers owner-pepper-backup owner-delivery receipts artifacts plugin-staging plugin-activation results; do
mkdir -m 0700 "$data_root/$directory"
done
cat >"$data_root/setup.json" <<EOF
{"schemaVersion":1,"operation":"local.setup.prepare","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","profile":"$profile","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","ownerPepperBackupDirectory":"/var/lib/qinglong3/owner-pepper-backup","ownerPepperKeyId":"owner-v1","localSecretKeyringPath":"/var/lib/qinglong3/local-secret-keyring.json","busyTimeoutMs":100},"request":{"registerMutationId":"019f8680-143d-4000-8000-000000000011","activateMutationId":"019f8680-143d-4000-8000-000000000012","registeredAtMs":1785254400000,"activatedAtMs":1785254400001}}
EOF
cat >"$data_root/owner-provision.json" <<EOF
{"schemaVersion":1,"operation":"owner.identity.provision","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","pepperPath":"/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper","pepperKeyId":"owner-v1","secretDeliveryDirectory":"/var/lib/qinglong3/owner-delivery","profile":"$profile","busyTimeoutMs":100},"request":{"mutationId":"019f8680-143d-4000-8000-000000000021","requestId":"alpha-trial-owner-provision"}}
EOF
cat >"$data_root/owner-challenge.json" <<EOF
{"schemaVersion":1,"operation":"owner.challenge.issue","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","pepperPath":"/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper","pepperKeyId":"owner-v1","secretDeliveryDirectory":"/var/lib/qinglong3/owner-delivery","profile":"$profile","busyTimeoutMs":100},"request":{"projectId":"default","mutationId":"019f8680-143d-4000-8000-000000000022","requestId":"alpha-trial-owner-challenge"}}
EOF
cat >"$data_root/owner-claim.json" <<EOF
{"schemaVersion":1,"operation":"owner.claim.from-deliveries","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","pepperPath":"/var/lib/qinglong3/owner-peppers/b3duZXItdjE.pepper","pepperKeyId":"owner-v1","secretDeliveryDirectory":"/var/lib/qinglong3/owner-delivery","profile":"$profile","busyTimeoutMs":100},"request":{"projectId":"default","mutationId":"019f8680-143d-4000-8000-000000000023","requestId":"alpha-trial-owner-claim","credentialMutationId":"019f8680-143d-4000-8000-000000000021","challengeMutationId":"019f8680-143d-4000-8000-000000000022"}}
EOF
cat >"$data_root/local-application.json" <<EOF
{"schema":"qinglong/local-application-process@v2","instanceId":"alpha-trial-local","profile":"$profile","storage":{"mode":"fresh","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","busyTimeoutMs":100},"runtime":{"receiptRoot":"/var/lib/qinglong3/receipts","artifactRoot":"/var/lib/qinglong3/artifacts","secretKeyringPath":"/var/lib/qinglong3/local-secret-keyring.json"},"pluginPackages":{"stagingRoot":"/var/lib/qinglong3/plugin-staging","activationRoot":"/var/lib/qinglong3/plugin-activation","recoverySource":{"mode":"disabled"},"pageSize":4,"maxPages":4,"taskPublicationPageSize":4,"taskPublicationMaxPages":4},"ai":{"deployment":"excluded"}}
EOF
chmod 0600 "$data_root"/*.json
uid=$(id -u)
gid=$(id -g)
run_operator() {
command_name=$1
command_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=$data_root,dst=/var/lib/qinglong3" \
"$OPERATOR_IMAGE" "$command_name" run \
--command-file "/var/lib/qinglong3/$command_file" \
>"$data_root/results/$command_file.result.json"
}
run_operator setup setup.json
grep -q '"status":"prepared"' "$data_root/results/setup.json.result.json" || fail 'fresh setup did not report prepared'
run_operator owner owner-provision.json
grep -q '"status":"inserted"' "$data_root/results/owner-provision.json.result.json" || fail 'Owner credential provisioning did not report inserted'
run_operator owner owner-challenge.json
grep -q '"status":"inserted"' "$data_root/results/owner-challenge.json.result.json" || fail 'Owner challenge did not report inserted'
run_operator owner owner-claim.json
grep -q '"status":"inserted"' "$data_root/results/owner-claim.json.result.json" || fail 'Owner claim did not report inserted'
grep -q '"role":"owner"' "$data_root/results/owner-claim.json.result.json" || fail 'Owner claim did not establish the owner role'
ready=0
cleanup() {
if [ "$ready" -ne 1 ]; then
docker rm --force "$container_name" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
trap 'exit 130' HUP INT TERM
container_id=$(docker run --detach --name "$container_name" \
--restart unless-stopped --read-only --user "$uid:$gid" --network none \
--cap-drop ALL --security-opt no-new-privileges \
--memory "$memory" --memory-swap "$memory" --cpus 0.5 --pids-limit "$pids" \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m \
--mount "type=bind,src=$data_root,dst=/var/lib/qinglong3" \
"$APPLICATION_IMAGE" --config /var/lib/qinglong3/local-application.json)
printf '%s\n' "$container_id" >"$data_root/container.id"
chmod 0600 "$data_root/container.id"
attempt=0
while [ "$attempt" -lt 45 ]; do
if docker logs "$container_name" 2>&1 | grep -q '"event":"active"'; then
ready=1
break
fi
running=$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)
[ "$running" = true ] || fail 'application stopped before becoming active'
attempt=$((attempt + 1))
sleep 1
done
[ "$ready" -eq 1 ] || fail 'application did not become active within 45 seconds'
umask "$old_umask"
printf '%s\n' \
"QingLong 3.0 Local Alpha is active ($profile, $ARCHITECTURE)." \
"Data root: $data_root" \
"Owner deliveries: $data_root/owner-delivery" \
"Logs: docker logs $container_name" \
"Stop: docker stop --time 30 $container_name" \
"Remove container: docker rm $container_name" \
'The fresh data root is retained until you remove it explicitly.'