feat(ql3): ship selectable local console trial kit

This commit is contained in:
whyour
2026-08-28 06:50:37 +08:00
parent 84d1a61372
commit c60ab7e48a
31 changed files with 1339 additions and 192 deletions
+30 -13
View File
@@ -12,7 +12,7 @@ const { sha256File } = require('./ql3-local-alpha-trial-kit-bundle.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-stage-index@v1';
const SCHEMA = 'qinglong/alpha-stage-index@v2';
const FILES = Object.freeze({
readme: 'README.md',
manifest: 'manifest.json',
@@ -118,8 +118,10 @@ function stageArtifactName(sourceRevision) {
return `ql3-alpha-${sourceRevision}-stage-index`;
}
function milestoneArtifactName(sourceRevision, product) {
return `ql3-alpha-${sourceRevision}-${product}-milestone`;
function milestoneArtifactName(sourceRevision, product, variant) {
return product === 'local'
? `ql3-alpha-${sourceRevision}-local-${variant}-milestone`
: `ql3-alpha-${sourceRevision}-cluster-milestone`;
}
function validateWorkflow(document, sourceRevision) {
@@ -189,8 +191,15 @@ function readMilestones(localMilestoneRoot, clusterMilestoneRoot) {
function expectedSelections(local, cluster) {
return {
local: {
profiles: ['edge', 'standalone'],
intent: 'fresh_non_production_trial',
variant: local.variant,
profiles:
local.variant === 'console'
? ['edge-application-api', 'standalone-application-api']
: ['edge', 'standalone'],
intent:
local.variant === 'console'
? 'fresh_loopback_console_non_production_trial'
: 'fresh_non_production_trial',
architectures: Object.fromEntries(
ARCHITECTURES.map((architecture) => [
architecture,
@@ -224,18 +233,19 @@ function expectedSelections(local, cluster) {
};
}
function validateMilestoneRecord(record, product, sourceRevision) {
function validateMilestoneRecord(record, product, sourceRevision, variant) {
const expectedMaturity =
product === 'local'
? 'alpha_candidate_not_public_release'
: 'cluster_integration_candidate_not_public_release';
const expectedSchema =
product === 'local'
? 'qinglong/alpha-local-milestone@v1'
? 'qinglong/alpha-local-milestone@v2'
: 'qinglong/alpha-cluster-milestone@v1';
if (
!exactKeys(record, ['artifactName', 'schema', 'maturity', 'manifest']) ||
record.artifactName !== milestoneArtifactName(sourceRevision, product) ||
record.artifactName !==
milestoneArtifactName(sourceRevision, product, variant) ||
record.schema !== expectedSchema ||
record.maturity !== expectedMaturity ||
!exactKeys(record.manifest, ['file', 'sha256', 'bytes']) ||
@@ -293,7 +303,7 @@ function auditAlphaStageIndex(options) {
'deploymentSelections',
'readme',
]) ||
manifest.schemaVersion !== 1 ||
manifest.schemaVersion !== 2 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_stage_delivery_not_public_release' ||
manifest.product !== 'qinglong3' ||
@@ -321,11 +331,13 @@ function auditAlphaStageIndex(options) {
manifest.milestones.local,
'local',
manifest.sourceRevision,
milestones.local.variant,
);
validateMilestoneRecord(
manifest.milestones.cluster,
'cluster',
manifest.sourceRevision,
undefined,
);
const milestoneManifests = {
local: fileRecord(
@@ -365,12 +377,15 @@ function auditAlphaStageIndex(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-stage-index-audit@v1',
schema: 'qinglong/alpha-stage-index-audit@v2',
version: manifest.version,
sourceRevision: manifest.sourceRevision,
workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt,
profiles: ['edge', 'standalone', 'cluster'],
profiles: [
...manifest.deploymentSelections.local.profiles,
'cluster',
],
artifactCount: 10,
compatible: true,
});
@@ -441,7 +456,7 @@ function finalizeAlphaStageIndex(options) {
path.join(normalized.outputRoot, FILES.readme),
);
const manifest = {
schemaVersion: 1,
schemaVersion: 2,
schema: SCHEMA,
maturity: 'alpha_stage_delivery_not_public_release',
product: 'qinglong3',
@@ -461,6 +476,7 @@ function finalizeAlphaStageIndex(options) {
artifactName: milestoneArtifactName(
normalized.sourceRevision,
'local',
normalized.local.variant,
),
schema: normalized.local.schema,
maturity: normalized.local.maturity,
@@ -473,6 +489,7 @@ function finalizeAlphaStageIndex(options) {
artifactName: milestoneArtifactName(
normalized.sourceRevision,
'cluster',
undefined,
),
schema: normalized.cluster.schema,
maturity: normalized.cluster.maturity,
@@ -542,7 +559,7 @@ function auditAlphaStageIndexWorkflow(root = DEFAULT_ROOT) {
`if: ${condition}`,
' - local-alpha-milestone\n',
' - cluster-alpha-milestone\n',
`name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-milestone`,
`name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`,
'scripts/ql3-alpha-stage-index.cjs',
'--mode=finalize',
+25 -1
View File
@@ -76,6 +76,30 @@ const IMAGE_PROFILES = Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
}),
}),
'local-console': Object.freeze({
id: 'local-console',
buildManifestPath: 'deploy/containers/ql3-local-application/package.json',
buildLockPath: 'deploy/containers/ql3-local-application/package-lock.json',
imageManifestPath:
'deploy/containers/ql3-local-application/runtime-dependencies/package.json',
imageLockPath:
'deploy/containers/ql3-local-application/runtime-dependencies/package-lock.json',
internalManifestPaths: Object.freeze([
'packages/ql3-runtime-core/package.json',
'packages/ql3-local-admin/package.json',
'packages/ql3-local-api/package.json',
'packages/ql3-local-application/package.json',
'packages/ql3-local-command-file/package.json',
'packages/ql3-local-execution/package.json',
'packages/ql3-local-owner-console/package.json',
'packages/ql3-local-process/package.json',
'packages/ql3-local-secret/package.json',
'packages/ql3-local-sqlite/package.json',
]),
buildOnlyDependencies: Object.freeze({
'drizzle-orm': '1.0.0-rc.4',
}),
}),
'local-operator': Object.freeze({
id: 'local-operator',
buildManifestPath: 'deploy/containers/ql3-local-operator/package.json',
@@ -132,7 +156,7 @@ function resolveImageProfile(value = 'control') {
const profile = IMAGE_PROFILES[value];
if (!profile) {
throw new Error(
'image profile must be exactly control, control-ai, admin, local, local-operator or worker',
'image profile must be exactly control, control-ai, admin, local, local-console, local-operator or worker',
);
}
return profile;
@@ -12,6 +12,7 @@ const IMAGES = Object.freeze([
'control',
'control-ai',
'local',
'local-console',
'local-operator',
'worker',
]);
@@ -109,6 +110,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0,
'control-ai': 0,
local: 0,
'local-console': 0,
'local-operator': 0,
worker: 0,
}),
@@ -120,6 +122,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0,
'control-ai': 0,
local: 0,
'local-console': 0,
'local-operator': 0,
worker: 0,
};
+31 -12
View File
@@ -7,11 +7,12 @@ const path = require('node:path');
const {
auditLocalAlphaTrialKit,
sha256File,
VARIANTS,
} = require('./ql3-local-alpha-trial-kit-bundle.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-milestone@v1';
const SCHEMA = 'qinglong/alpha-local-milestone@v2';
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const FILES = Object.freeze({
readme: 'README.md',
@@ -126,8 +127,8 @@ function checksumContents(root, names) {
.join('\n')}\n`;
}
function artifactName(sourceRevision, architecture) {
return `ql3-alpha-${sourceRevision}-local-${architecture}`;
function artifactName(sourceRevision, architecture, variant = 'headless') {
return `ql3-alpha-${sourceRevision}-local-${variant}-${architecture}`;
}
function validateIdentity(options) {
@@ -150,6 +151,7 @@ function validateFinalizeOptions(options) {
const outputRoot = path.resolve(options.outputRoot || '');
const parent = path.dirname(outputRoot);
if (
!VARIANTS.includes(options.variant) ||
!path.isAbsolute(outputRoot) ||
fs.existsSync(outputRoot) ||
fs.realpathSync(parent) !== parent
@@ -179,6 +181,7 @@ function validateFinalizeOptions(options) {
'milestone README',
),
sourceRevision: options.sourceRevision,
variant: options.variant,
repository: options.repository,
workflowRef: options.workflowRef,
workflowSha: options.workflowSha,
@@ -196,12 +199,17 @@ function bundleRecord(options, architecture) {
report.architecture !== architecture ||
report.sourceRevision !== options.sourceRevision ||
report.workflowRunId !== options.runId ||
report.workflowRunAttempt !== options.runAttempt
report.workflowRunAttempt !== options.runAttempt ||
report.variant !== options.variant
) {
fail(`${architecture} trial kit is detached from the milestone run`);
}
return Object.freeze({
artifactName: artifactName(options.sourceRevision, architecture),
artifactName: artifactName(
options.sourceRevision,
architecture,
options.variant,
),
architecture,
bundleManifest: fileRecord(
path.join(bundleRoot, 'manifest.json'),
@@ -226,7 +234,7 @@ function validateArtifactRecord(record, architecture, manifest) {
'verificationSha256',
]) ||
record.artifactName !==
artifactName(manifest.sourceRevision, architecture) ||
artifactName(manifest.sourceRevision, architecture, manifest.variant) ||
record.architecture !== architecture ||
!exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) ||
record.bundleManifest.file !== 'manifest.json' ||
@@ -298,16 +306,18 @@ function auditLocalAlphaMilestone(options) {
'schema',
'maturity',
'product',
'variant',
'version',
'sourceRevision',
'workflow',
'artifacts',
'readme',
]) ||
manifest.schemaVersion !== 1 ||
manifest.schemaVersion !== 2 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
!VARIANTS.includes(manifest.variant) ||
typeof manifest.version !== 'string' ||
manifest.version.length < 3 ||
manifest.version.length > 64 ||
@@ -373,9 +383,10 @@ function auditLocalAlphaMilestone(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-milestone-audit@v1',
schema: 'qinglong/alpha-local-milestone-audit@v2',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
variant: manifest.variant,
workflowRunId: manifest.workflow.runId,
workflowRunAttempt: manifest.workflow.runAttempt,
architectures: [...ARCHITECTURES],
@@ -422,10 +433,11 @@ function finalizeLocalAlphaMilestone(options) {
path.join(normalized.outputRoot, FILES.readme),
);
const manifest = {
schemaVersion: 1,
schemaVersion: 2,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
variant: normalized.variant,
version: [...versions][0],
sourceRevision: normalized.sourceRevision,
workflow: {
@@ -492,6 +504,10 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
'- local',
'- cluster',
'- all',
'local_alpha_variant:',
'default: headless',
'- headless',
'- console',
"github.run_id || 'validation'",
"cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}",
];
@@ -512,12 +528,13 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
' name: Finalize the Local Alpha milestone',
' needs:',
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c',
`name: ql3-alpha-${'${{ github.sha }}'}-local-amd64`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-arm64`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-amd64`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-arm64`,
'scripts/ql3-local-alpha-milestone.cjs',
'--mode=finalize',
'--variant=${{ inputs.local_alpha_variant }}',
'--mode=audit',
`name: ql3-alpha-${'${{ github.sha }}'}-local-milestone`,
`name: ql3-alpha-${'${{ github.sha }}'}-local-${'${{ inputs.local_alpha_variant }}'}-milestone`,
'retention-days: 30',
'overwrite: false',
];
@@ -587,6 +604,7 @@ function parseArguments(argv) {
'run-attempt',
'run-id',
'source-revision',
'variant',
'workflow-ref',
'workflow-sha',
];
@@ -605,6 +623,7 @@ function parseArguments(argv) {
},
readme: path.resolve(values.readme),
sourceRevision: values['source-revision'],
variant: values.variant,
repository: values.repository,
workflowRef: values['workflow-ref'],
workflowSha: values['workflow-sha'],
+61 -18
View File
@@ -10,13 +10,14 @@ 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@v3';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v1';
const SCHEMA = 'qinglong/alpha-local-trial-kit@v4';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
@@ -43,6 +44,13 @@ const VERIFICATION = Object.freeze({
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
});
function verificationGates(variant) {
return Object.freeze({
...VERIFICATION,
consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable',
});
}
const WORKFLOW_IDENTITY = Object.freeze({
repository: 'whyour/qinglong',
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
@@ -162,11 +170,13 @@ function validateImageReference(value, label) {
}
function normalizeImageInspection(inspection, options) {
const { architecture, reference, revision, role, version } = options;
const { architecture, reference, revision, role, variant, version } = options;
const labels = inspection?.Config?.Labels;
const expectedTitle =
role === 'application'
? 'QingLong 3.0 Local Application'
? variant === 'console'
? 'QingLong 3.0 Local Console Application'
: 'QingLong 3.0 Local Application'
: 'QingLong 3.0 Local Operator';
if (
!SHA256_PATTERN.test(inspection?.Id || '') ||
@@ -183,8 +193,14 @@ function normalizeImageInspection(inspection, options) {
}
if (
role === 'application' &&
(labels?.['io.qinglong.profile'] !== 'edge,standalone' ||
labels?.['io.qinglong.ai'] !== 'excluded')
(labels?.['io.qinglong.profile'] !==
(variant === 'console'
? 'edge-application-api,standalone-application-api'
: 'edge,standalone') ||
labels?.['io.qinglong.ai'] !== 'excluded' ||
(variant === 'console'
? labels?.['io.qinglong.local.console'] !== 'offline-loopback'
: labels?.['io.qinglong.local.console'] !== undefined))
) {
fail('application image profile is incompatible');
}
@@ -259,12 +275,14 @@ function validateVerificationEvidence(document, expected) {
'version',
'sourceRevision',
'architecture',
'variant',
'applicationImageId',
'operatorImageId',
]) ||
document.subject.version !== expected.version ||
document.subject.sourceRevision !== expected.sourceRevision ||
document.subject.architecture !== expected.architecture ||
document.subject.variant !== expected.variant ||
document.subject.applicationImageId !== expected.applicationImageId ||
document.subject.operatorImageId !== expected.operatorImageId ||
document.subject.applicationImageId === document.subject.operatorImageId ||
@@ -284,8 +302,9 @@ function validateVerificationEvidence(document, expected) {
document.workflow.job !== WORKFLOW_IDENTITY.job ||
!DECIMAL_ID_PATTERN.test(document.workflow.runId || '') ||
!ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') ||
!exactKeys(document.gates, Object.keys(VERIFICATION)) ||
JSON.stringify(document.gates) !== JSON.stringify(VERIFICATION)
!exactKeys(document.gates, Object.keys(verificationGates(expected.variant))) ||
JSON.stringify(document.gates) !==
JSON.stringify(verificationGates(expected.variant))
) {
fail('trial kit verification evidence is incompatible');
}
@@ -298,6 +317,7 @@ function validateVerificationOptions(options) {
const parent = path.dirname(output);
if (
!ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(output) ||
fs.existsSync(output) ||
@@ -316,6 +336,7 @@ function validateVerificationOptions(options) {
root,
output,
architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision,
applicationImage: validateImageReference(
options.applicationImage,
@@ -343,6 +364,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
reference: normalized.applicationImage,
revision: normalized.sourceRevision,
role: 'application',
variant: normalized.variant,
version: release.version,
},
);
@@ -353,6 +375,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
reference: normalized.operatorImage,
revision: normalized.sourceRevision,
role: 'operator',
variant: normalized.variant,
version: release.version,
},
);
@@ -364,6 +387,7 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id,
operatorImageId: operator.id,
},
@@ -376,15 +400,17 @@ function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
runId: normalized.runId,
runAttempt: normalized.runAttempt,
},
gates: { ...VERIFICATION },
gates: { ...verificationGates(normalized.variant) },
};
validateVerificationEvidence(evidence, evidence.subject);
writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`);
return evidence;
}
function archiveName(architecture) {
return `qinglong3-local-trial-kit-${architecture}.docker.tar`;
function archiveName(architecture, variant = 'headless') {
return variant === 'console'
? `qinglong3-local-console-trial-kit-${architecture}.docker.tar`
: `qinglong3-local-trial-kit-${architecture}.docker.tar`;
}
function renderQuickstart(identity) {
@@ -404,6 +430,7 @@ function renderQuickstart(identity) {
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
'@@VARIANT@@': identity.variant,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
@@ -442,6 +469,7 @@ function validateCreateOptions(options) {
const parent = path.dirname(outputRoot);
if (
!ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(outputRoot) ||
fs.existsSync(outputRoot) ||
@@ -453,6 +481,7 @@ function validateCreateOptions(options) {
root,
outputRoot,
architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision,
applicationImage: validateImageReference(
options.applicationImage,
@@ -501,7 +530,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
);
validateSbom(applicationSbom, {
root: normalized.root,
profile: 'local',
profile: normalized.variant === 'console' ? 'local-console' : 'local',
version: release.version,
});
validateSbom(operatorSbom, {
@@ -516,6 +545,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
reference: normalized.applicationImage,
revision: normalized.sourceRevision,
role: 'application',
variant: normalized.variant,
version: release.version,
},
);
@@ -526,6 +556,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
reference: normalized.operatorImage,
revision: normalized.sourceRevision,
role: 'operator',
variant: normalized.variant,
version: release.version,
},
);
@@ -534,6 +565,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id,
operatorImageId: operator.id,
});
@@ -542,7 +574,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
try {
fs.mkdirSync(normalized.outputRoot, { mode: 0o700 });
created = true;
const archive = archiveName(normalized.architecture);
const archive = archiveName(normalized.architecture, normalized.variant);
const archivePath = path.join(normalized.outputRoot, archive);
saveImages(
[normalized.applicationImage, normalized.operatorImage],
@@ -576,6 +608,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
const manifestIdentity = {
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
archive: { file: archive },
images: { application, operator },
};
@@ -585,13 +618,14 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
0o700,
);
const manifest = {
schemaVersion: 4,
schemaVersion: 5,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
archive: fileRecord(normalized.outputRoot, archive),
images: { application, operator },
sboms: {
@@ -679,6 +713,7 @@ function auditLocalAlphaTrialKit(options) {
'version',
'sourceRevision',
'architecture',
'variant',
'archive',
'images',
'sboms',
@@ -686,13 +721,14 @@ function auditLocalAlphaTrialKit(options) {
'readme',
'verification',
]) ||
manifest.schemaVersion !== 4 ||
manifest.schemaVersion !== 5 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
typeof manifest.version !== 'string' ||
!REVISION_PATTERN.test(manifest.sourceRevision || '') ||
!ARCHITECTURES.includes(manifest.architecture) ||
!VARIANTS.includes(manifest.variant) ||
!exactKeys(manifest.images, ['application', 'operator']) ||
!exactKeys(manifest.sboms, ['application', 'operator'])
) {
@@ -703,7 +739,7 @@ function auditLocalAlphaTrialKit(options) {
if (manifest.images.application.id === manifest.images.operator.id) {
fail('trial kit images must be distinct');
}
const expectedArchive = archiveName(manifest.architecture);
const expectedArchive = archiveName(manifest.architecture, manifest.variant);
validateFileRecord(manifest.archive, expectedArchive, bundleRoot);
if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) {
fail('Docker archive is unexpectedly small');
@@ -738,7 +774,7 @@ function auditLocalAlphaTrialKit(options) {
path.join(bundleRoot, FILES.applicationSbom),
'application SBOM',
),
'local',
manifest.variant === 'console' ? 'local-console' : 'local',
manifest.version,
);
validateOfflineSbom(
@@ -755,6 +791,7 @@ function auditLocalAlphaTrialKit(options) {
version: manifest.version,
sourceRevision: manifest.sourceRevision,
architecture: manifest.architecture,
variant: manifest.variant,
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
},
@@ -800,10 +837,11 @@ function auditLocalAlphaTrialKit(options) {
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v1',
schema: 'qinglong/alpha-local-trial-kit-audit@v2',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
architecture: manifest.architecture,
variant: manifest.variant,
archiveSha256: manifest.archive.sha256,
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
@@ -845,6 +883,7 @@ function parseArguments(argv) {
'run-attempt',
'run-id',
'source-revision',
'variant',
'workflow-ref',
'workflow-sha',
];
@@ -857,6 +896,7 @@ function parseArguments(argv) {
mode: 'record-verification',
output: path.resolve(values.output),
architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'],
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
@@ -880,6 +920,7 @@ function parseArguments(argv) {
'output',
'readme',
'source-revision',
'variant',
'verification-evidence',
];
if (
@@ -891,6 +932,7 @@ function parseArguments(argv) {
mode: 'create',
outputRoot: path.resolve(values.output),
architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'],
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
@@ -933,6 +975,7 @@ module.exports = Object.freeze({
SCHEMA,
VERIFICATION,
VERIFICATION_SCHEMA,
VARIANTS,
archiveName,
auditLocalAlphaTrialKit,
createLocalAlphaTrialKit,
@@ -20,27 +20,30 @@ function fail(message) {
function argumentsFrom(argv) {
const values = {};
for (const argument of argv) {
const match = /^--(application-image|operator-image|profile)=(.+)$/u.exec(
argument,
);
const match =
/^--(application-image|operator-image|profile|variant)=(.+)$/u.exec(
argument,
);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (
Object.keys(values).length !== 3 ||
Object.keys(values).length !== 4 ||
!IMAGE_PATTERN.test(values['application-image'] ?? '') ||
!IMAGE_PATTERN.test(values['operator-image'] ?? '') ||
!['edge', 'standalone'].includes(values.profile)
!['edge', 'standalone'].includes(values.profile) ||
!['headless', 'console'].includes(values.variant)
) {
fail(
'usage: --application-image=... --operator-image=... --profile=edge|standalone',
'usage: --application-image=... --operator-image=... --profile=edge|standalone --variant=headless|console',
);
}
return Object.freeze({
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
profile: values.profile,
variant: values.variant,
});
}
@@ -61,41 +64,54 @@ function docker(args, options = {}) {
return result.stdout.trim();
}
function inspectImages(applicationImage, operatorImage) {
const application = docker([
'image',
'inspect',
'--format',
'{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}',
applicationImage,
]).split(' ');
const operator = docker([
'image',
'inspect',
'--format',
'{{.Id}} {{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.lifecycle"}} {{index .Config.Labels "io.qinglong.authority"}} {{index .Config.Labels "org.opencontainers.image.revision"}} {{index .Config.Labels "org.opencontainers.image.version"}}',
operatorImage,
]).split(' ');
function inspectImages(applicationImage, operatorImage, variant) {
const application = JSON.parse(
docker(['image', 'inspect', applicationImage]),
)[0];
const operator = JSON.parse(docker(['image', 'inspect', operatorImage]))[0];
const applicationLabels = application?.Config?.Labels;
const operatorLabels = operator?.Config?.Labels;
const expectedTitle =
variant === 'console'
? 'QingLong 3.0 Local Console Application'
: 'QingLong 3.0 Local Application';
const expectedProfile =
variant === 'console'
? 'edge-application-api,standalone-application-api'
: 'edge,standalone';
if (
!/^sha256:[0-9a-f]{64}$/u.test(application[0] ?? '') ||
!/^sha256:[0-9a-f]{64}$/u.test(operator[0] ?? '') ||
application[1] !== operator[1] ||
!['amd64', 'arm64'].includes(application[1]) ||
application[2] !== '65532:65532' ||
operator[2] !== '65532:65532' ||
operator[3] !== 'short-lived' ||
operator[4] !== 'local-owner-management' ||
!/^[0-9a-f]{40}$/u.test(application[3] ?? '') ||
application[3] !== operator[5] ||
application[4] !== operator[6] ||
!/^3\.0\.0-alpha\.[0-9]+$/u.test(application[4] ?? '')
!/^sha256:[0-9a-f]{64}$/u.test(application?.Id ?? '') ||
!/^sha256:[0-9a-f]{64}$/u.test(operator?.Id ?? '') ||
application?.Architecture !== operator?.Architecture ||
!['amd64', 'arm64'].includes(application?.Architecture) ||
application?.Config?.User !== '65532:65532' ||
operator?.Config?.User !== '65532:65532' ||
applicationLabels?.['org.opencontainers.image.title'] !== expectedTitle ||
applicationLabels?.['io.qinglong.profile'] !== expectedProfile ||
applicationLabels?.['io.qinglong.ai'] !== 'excluded' ||
(variant === 'console'
? applicationLabels?.['io.qinglong.local.console'] !==
'offline-loopback'
: applicationLabels?.['io.qinglong.local.console'] !== undefined) ||
operatorLabels?.['io.qinglong.lifecycle'] !== 'short-lived' ||
operatorLabels?.['io.qinglong.authority'] !== 'local-owner-management' ||
!/^[0-9a-f]{40}$/u.test(
applicationLabels?.['org.opencontainers.image.revision'] ?? '',
) ||
applicationLabels?.['org.opencontainers.image.revision'] !==
operatorLabels?.['org.opencontainers.image.revision'] ||
applicationLabels?.['org.opencontainers.image.version'] !==
operatorLabels?.['org.opencontainers.image.version'] ||
!/^3\.0\.0-alpha\.[0-9]+$/u.test(
applicationLabels?.['org.opencontainers.image.version'] ?? '',
)
) {
fail('image identity, architecture or authority labels drifted');
}
return Object.freeze({
architecture: application[1],
applicationId: application[0],
operatorId: operator[0],
architecture: application.Architecture,
applicationId: application.Id,
operatorId: operator.Id,
});
}
@@ -326,6 +342,55 @@ function writeApplicationConfig(state) {
},
ai: { deployment: 'excluded' },
});
if (state.variant === 'console') {
writePrivateJson(path.join(state.root, 'local-api.json'), {
schema: 'qinglong/local-api-process@v1',
deploymentRoot: '/var/lib/qinglong3',
applicationConfigFilePath:
'/var/lib/qinglong3/local-application.json',
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
listener: { host: '127.0.0.1', port: 5700 },
});
}
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function consoleSurfaceContract() {
let lastError;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const root = await fetch('http://127.0.0.1:5700/', {
redirect: 'manual',
signal: AbortSignal.timeout(2_000),
});
const api = await fetch(
'http://127.0.0.1:5700/api/v3/projects/default/tasks',
{
redirect: 'manual',
signal: AbortSignal.timeout(2_000),
},
);
await root.body?.cancel();
await api.body?.cancel();
if (root.status !== 200 || api.status !== 401) {
fail(
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
);
}
return Object.freeze({
listener: '127.0.0.1:5700',
rootStatus: 200,
unauthenticatedApiStatus: 401,
});
} catch (error) {
lastError = error;
await delay(250);
}
}
throw lastError || new Error('Console listener did not become ready');
}
async function runApplication(state) {
@@ -344,7 +409,7 @@ async function runApplication(state) {
'--user',
`${state.uid}:${state.gid}`,
'--network',
'none',
state.variant === 'console' ? 'host' : 'none',
'--cap-drop',
'ALL',
'--security-opt',
@@ -363,13 +428,18 @@ async function runApplication(state) {
`${state.root}:/var/lib/qinglong3`,
state.applicationImage,
'--config',
'/var/lib/qinglong3/local-application.json',
state.variant === 'console'
? '/var/lib/qinglong3/local-api.json'
: '/var/lib/qinglong3/local-application.json',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let stdout = '';
let stderr = '';
let active = false;
let surfaceError;
let surface = Object.freeze({ status: 'not_applicable' });
let surfacePromise = Promise.resolve();
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
@@ -385,7 +455,17 @@ async function runApplication(state) {
})
) {
active = true;
docker(['stop', '--time', '30', name]);
surfacePromise = (async () => {
try {
if (state.variant === 'console') {
surface = await consoleSurfaceContract();
}
} catch (error) {
surfaceError = error;
} finally {
docker(['stop', '--time', '30', name]);
}
})();
}
});
child.stderr.on('data', (chunk) => {
@@ -403,6 +483,8 @@ async function runApplication(state) {
resolve({ code, signal });
});
});
await surfacePromise;
if (surfaceError) throw surfaceError;
const events = stdout
.trim()
.split('\n')
@@ -426,7 +508,7 @@ async function runApplication(state) {
})}`,
);
}
return Object.freeze({ active: true, gracefulStop: true });
return Object.freeze({ active: true, gracefulStop: true, surface });
} finally {
spawnSync('docker', ['rm', '--force', name], { stdio: 'ignore' });
}
@@ -441,7 +523,11 @@ async function main() {
fail('a POSIX identity is required');
}
const options = argumentsFrom(process.argv.slice(2));
const images = inspectImages(options.applicationImage, options.operatorImage);
const images = inspectImages(
options.applicationImage,
options.operatorImage,
options.variant,
);
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-alpha-trial-')),
);
@@ -479,8 +565,9 @@ async function main() {
fail('durable SQLite result is invalid');
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
schema: 'qinglong/local-alpha-trial-kit-live@v1',
schemaVersion: 2,
schema: 'qinglong/local-alpha-trial-kit-live@v2',
variant: options.variant,
profile: options.profile,
architecture: images.architecture,
images: {
@@ -0,0 +1,144 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const MAX_FILES = 640;
const MAX_BYTES = 6 * 1024 * 1024;
const EXPECTED_PACKAGES = Object.freeze([
'@qinglong/local-admin',
'@qinglong/local-api',
'@qinglong/local-application',
'@qinglong/local-command-file',
'@qinglong/local-execution',
'@qinglong/local-owner-console',
'@qinglong/local-process',
'@qinglong/local-secret',
'@qinglong/local-sqlite',
'@qinglong/runtime-core',
'croner',
'semver',
]);
function fail(message) {
throw new Error(`QingLong local Console image inventory failed: ${message}`);
}
function inventoryRoot(argv) {
if (argv.length !== 1 || !argv[0].startsWith('--inventory-root=')) {
fail('usage: --inventory-root=/absolute/node_modules');
}
const root = argv[0].slice('--inventory-root='.length);
if (
!path.isAbsolute(root) ||
path.normalize(root) !== root ||
root === path.parse(root).root
) {
fail('inventory root is invalid');
}
const stat = fs.lstatSync(root);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
fs.realpathSync(root) !== root
) {
fail('inventory root must be a canonical directory');
}
return root;
}
function packageNames(root) {
const result = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
if (!entry.isDirectory() || entry.isSymbolicLink()) {
fail(`unexpected root entry ${entry.name}`);
}
if (!entry.name.startsWith('@')) {
result.push(entry.name);
continue;
}
const scope = path.join(root, entry.name);
for (const child of fs.readdirSync(scope, { withFileTypes: true })) {
if (
child.name.startsWith('.') ||
!child.isDirectory() ||
child.isSymbolicLink()
) {
fail(`unexpected scoped entry ${entry.name}/${child.name}`);
}
result.push(`${entry.name}/${child.name}`);
}
}
return result.sort();
}
function usage(root) {
const pending = [root];
let files = 0;
let bytes = 0;
while (pending.length > 0) {
const directory = pending.pop();
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name);
const stat = fs.lstatSync(filePath);
if (stat.isSymbolicLink()) fail('runtime inventory contains a symlink');
if (stat.isDirectory()) {
pending.push(filePath);
continue;
}
if (!stat.isFile()) fail('runtime inventory contains a special file');
files += 1;
bytes += stat.size;
if (files > MAX_FILES) fail('runtime file budget exceeded');
if (bytes > MAX_BYTES) fail('runtime byte budget exceeded');
}
}
return Object.freeze({ files, bytes });
}
function main() {
const root = inventoryRoot(process.argv.slice(2));
const packages = packageNames(root);
if (JSON.stringify(packages) !== JSON.stringify(EXPECTED_PACKAGES)) {
fail(`package closure drifted: ${packages.join(',')}`);
}
for (const packageName of packages) {
const manifest = JSON.parse(
fs.readFileSync(path.join(root, packageName, 'package.json'), 'utf8'),
);
if (
manifest.name !== packageName ||
typeof manifest.version !== 'string'
) {
fail(`package identity drifted: ${packageName}`);
}
}
const measured = usage(root);
process.stdout.write(
`${JSON.stringify({
schemaVersion: 1,
image: 'local-console',
packages,
packageCount: packages.length,
files: measured.files,
bytes: measured.bytes,
maxFiles: MAX_FILES,
maxBytes: MAX_BYTES,
ai: 'excluded',
listener: 'loopback-only',
compatible: true,
})}\n`,
);
}
try {
main();
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
+112 -14
View File
@@ -29,6 +29,18 @@ const RUNTIME_DEPENDENCIES = Object.freeze({
});
const BUILD_PACKAGES = Object.freeze([
'ql3-ai',
'ql3-local-admin',
'ql3-local-api',
'ql3-local-application',
'ql3-local-command-file',
'ql3-local-execution',
'ql3-local-owner-console',
'ql3-local-process',
'ql3-local-secret',
'ql3-local-sqlite',
'ql3-runtime-core',
]);
const RUNTIME_PACKAGES = Object.freeze([
'ql3-local-admin',
'ql3-local-application',
'ql3-local-command-file',
@@ -38,8 +50,8 @@ const BUILD_PACKAGES = Object.freeze([
'ql3-local-sqlite',
'ql3-runtime-core',
]);
const RUNTIME_PACKAGES = Object.freeze(
BUILD_PACKAGES.filter((name) => name !== 'ql3-ai'),
const CONSOLE_RUNTIME_PACKAGES = Object.freeze(
[...RUNTIME_PACKAGES, 'ql3-local-api', 'ql3-local-owner-console'].sort(),
);
function readJson(filePath) {
@@ -174,6 +186,15 @@ function counts(values) {
return result;
}
function dockerStage(contents, name) {
const marker = new RegExp(`^FROM [^\\n]+ AS ${name}$`, 'm');
const match = marker.exec(contents);
if (!match) return '';
const remaining = contents.slice(match.index + match[0].length);
const next = /^FROM [^\n]+ AS [a-z0-9-]+$/m.exec(remaining);
return next ? remaining.slice(0, next.index) : remaining;
}
function auditDockerfile(contents, findings) {
const escapedBuildNodeImage = BUILD_NODE_IMAGE.replace(
/[.*+?^${}()|[\]\\]/g,
@@ -188,7 +209,7 @@ function auditDockerfile(contents, findings) {
'gm',
);
const exactRuntimeBasePattern = new RegExp(
`^FROM ${escapedRuntimeNodeImage} AS runtime$`,
`^FROM ${escapedRuntimeNodeImage} AS runtime-platform$`,
'gm',
);
if (
@@ -221,8 +242,13 @@ function auditDockerfile(contents, findings) {
addFinding(findings, 'UNREVIEWED_RUNTIME_SURFACE');
}
const workspaceStage = dockerStage(contents, 'workspace');
const assembledStage = dockerStage(contents, 'assembled');
const consoleAssembledStage = dockerStage(contents, 'console-assembled');
const runtimeStage = dockerStage(contents, 'runtime');
const consoleRuntimeStage = dockerStage(contents, 'runtime-console');
const buildCopies = captures(
contents,
workspaceStage,
/^COPY packages\/(ql3-[a-z-]+) packages\/\1$/gm,
).sort();
if (!sameJson(buildCopies, [...BUILD_PACKAGES].sort())) {
@@ -230,7 +256,7 @@ function auditDockerfile(contents, findings) {
}
const runtimeCopyCounts = counts(
captures(
contents,
assembledStage,
/^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm,
),
);
@@ -244,6 +270,28 @@ function auditDockerfile(contents, findings) {
) {
addFinding(findings, 'RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT');
}
const consoleRuntimeCopyCounts = counts(
captures(
consoleAssembledStage,
/^COPY --from=workspace \/workspace\/packages\/(ql3-[a-z-]+)\/(?:package\.json|dist) /gm,
),
);
if (
!sameJson(
sortedObject(consoleRuntimeCopyCounts),
sortedObject(
Object.fromEntries(
CONSOLE_RUNTIME_PACKAGES.map((name) => [name, 2]),
),
),
) ||
!consoleAssembledStage.includes(
'COPY --from=workspace /workspace/packages/ql3-local-api/assets \\\n' +
' node_modules/@qinglong/local-api/assets',
)
) {
addFinding(findings, 'CONSOLE_RUNTIME_INTERNAL_PACKAGE_CLOSURE_DRIFT');
}
if (contents.includes('COPY --from=workspace /workspace/packages/ql3-ai/')) {
addFinding(findings, 'AI_PRESENT_IN_RUNTIME_STAGE');
}
@@ -264,20 +312,51 @@ function auditDockerfile(contents, findings) {
addFinding(findings, 'RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED');
}
if (
!contents.includes('USER 65532:65532') ||
!contents.includes(
!consoleAssembledStage.includes(
'RUN rm -rf node_modules/.bin \\\n' +
' && node /tmp/ql3-prune-runtime-artifact.cjs node_modules/@qinglong \\\n' +
' @qinglong/local-api/config \\\n' +
' @qinglong/local-api/process \\\n' +
' --exclude=@qinglong/ai \\\n' +
' --retain-js=local-api/assets/console/console.js \\\n' +
' && rm /tmp/ql3-prune-runtime-artifact.cjs',
)
) {
addFinding(findings, 'CONSOLE_RUNTIME_NONESSENTIAL_FILES_NOT_REMOVED');
}
if (
!runtimeStage.includes('USER 65532:65532') ||
!runtimeStage.includes(
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js"]',
) ||
!contents.includes('io.qinglong.ai="excluded"') ||
!contents.includes('io.qinglong.profile="edge,standalone"') ||
!contents.includes('io.qinglong.local.application-config="2,3,4"') ||
!contents.includes('io.qinglong.local.sqlite-contract-min="51"') ||
!contents.includes('io.qinglong.local.sqlite-contract-max="52"') ||
!contents.includes('io.qinglong.local.sqlite-write-contract="52"') ||
!contents.includes('io.qinglong.local.compose-selection="1"')
!runtimeStage.includes('io.qinglong.ai="excluded"') ||
!runtimeStage.includes('io.qinglong.profile="edge,standalone"') ||
!runtimeStage.includes('io.qinglong.local.application-config="2,3,4"') ||
!runtimeStage.includes('io.qinglong.local.sqlite-contract-min="51"') ||
!runtimeStage.includes('io.qinglong.local.sqlite-contract-max="52"') ||
!runtimeStage.includes('io.qinglong.local.sqlite-write-contract="52"') ||
!runtimeStage.includes('io.qinglong.local.compose-selection="1"')
) {
addFinding(findings, 'RUNTIME_IDENTITY_OR_LABEL_DRIFT');
}
if (
!consoleRuntimeStage.includes('USER 65532:65532') ||
!consoleRuntimeStage.includes(
'ENTRYPOINT ["node", "/opt/qinglong/node_modules/@qinglong/local-api/dist/cli.js"]',
) ||
!consoleRuntimeStage.includes(
'org.opencontainers.image.title="QingLong 3.0 Local Console Application"',
) ||
!consoleRuntimeStage.includes(
'io.qinglong.profile="edge-application-api,standalone-application-api"',
) ||
!consoleRuntimeStage.includes(
'io.qinglong.local.console="offline-loopback"',
) ||
!consoleRuntimeStage.includes('io.qinglong.ai="excluded"')
) {
addFinding(findings, 'CONSOLE_RUNTIME_IDENTITY_OR_LABEL_DRIFT');
}
}
function auditWorkflow(contents, findings) {
@@ -295,6 +374,9 @@ function auditWorkflow(contents, findings) {
'pnpm audit:local-image:ql3',
'docker build',
'--file deploy/containers/ql3-local-application/Dockerfile',
'--target runtime',
'--target runtime-console',
'qinglong3-local-console:ci-${{ matrix.image_arch }}',
'EXPECTED: ${{ matrix.image_arch }} 65532:65532 2,3,4 51 52 52 1',
'actual="$(docker image inspect --format \'{{.Architecture}} {{.Config.User}} {{index .Config.Labels "io.qinglong.local.application-config"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-min"}} {{index .Config.Labels "io.qinglong.local.sqlite-contract-max"}} {{index .Config.Labels "io.qinglong.local.sqlite-write-contract"}} {{index .Config.Labels "io.qinglong.local.compose-selection"}}\' "${IMAGE}")"',
'io.qinglong.local.application-config',
@@ -309,10 +391,18 @@ function auditWorkflow(contents, findings) {
'--memory=128m',
'--pids-limit=64',
'scripts/ql3-local-image-inventory.cjs',
'scripts/ql3-local-console-image-inventory.cjs',
'--inventory-root=/opt/qinglong/node_modules',
'node ../../scripts/ql3-build-package-closure.cjs',
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge',
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone',
'node scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
'--variant=headless',
'--variant=console',
'--image=local-console',
'io.qinglong.local.console',
'--variant="${TRIAL_VARIANT}"',
'inputs.local_alpha_variant',
];
for (const value of required) {
if (!job.includes(value)) {
@@ -356,6 +446,14 @@ function auditLocalImageContract(root) {
...Object.keys(RUNTIME_DEPENDENCIES),
].sort(),
),
consoleRuntimePackages: Object.freeze(
[
...CONSOLE_RUNTIME_PACKAGES.map(
(name) => `@qinglong/${name.slice(4)}`,
),
...Object.keys(RUNTIME_DEPENDENCIES),
].sort(),
),
findings: Object.freeze(findings),
compatible: findings.length === 0,
});
+4 -2
View File
@@ -199,10 +199,10 @@ function auditWorkflow(contents, findings) {
'--mode=record-verification',
'--mode=create',
'--mode=audit',
'/quickstart.sh" \\\n edge "${QUICKSTART_ROOT}" "${QUICKSTART_CONTAINER}"',
'sh "${BUNDLE_ROOT}/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"',
'--application-sbom="${APPLICATION_SBOM}"',
'--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"',
'--verification-evidence="${RUNNER_TEMP}/ql3-local-alpha-verification-${{ matrix.image_arch }}.json"',
'--readme=docs/operations/ql3-local-alpha-trial-kit.md',
@@ -213,6 +213,8 @@ function auditWorkflow(contents, findings) {
'--job=${{ github.job }}',
'--run-id=${{ github.run_id }}',
'--run-attempt=${{ github.run_attempt }}',
'--variant="${TRIAL_VARIANT}"',
'inputs.local_alpha_variant',
];
for (const value of required) {
if (!contents.includes(value))
+7 -2
View File
@@ -760,17 +760,22 @@ if (require.main === module) {
try {
if (process.argv.length < 4) {
fail(
'usage: ql3-prune-runtime-artifact.cjs node_modules/@qinglong @qinglong/profile-entry [...]',
'usage: ql3-prune-runtime-artifact.cjs node_modules/@qinglong @qinglong/profile-entry [...] [--exclude=@qinglong/package] [--retain-js=package/path.js]',
);
}
const arguments = process.argv.slice(3);
const report = pruneRuntimeArtifact(process.argv[2], {
entrySpecifiers: arguments.filter(
(argument) => !argument.startsWith('--exclude='),
(argument) =>
!argument.startsWith('--exclude=') &&
!argument.startsWith('--retain-js='),
),
excludedInternalPackages: arguments
.filter((argument) => argument.startsWith('--exclude='))
.map((argument) => argument.slice('--exclude='.length)),
retainedJavaScriptFiles: arguments
.filter((argument) => argument.startsWith('--retain-js='))
.map((argument) => argument.slice('--retain-js='.length)),
});
process.stdout.write(
`${JSON.stringify({ schemaVersion: 1, ...report })}\n`,
+7 -1
View File
@@ -25,6 +25,12 @@ const CONTAINER_ROOTS = Object.freeze([
'deploy/containers/ql3-local-application',
'deploy/containers/ql3-worker',
]);
const CONTAINER_DOCKER_VERSION_LABEL_COUNTS = Object.freeze({
'deploy/containers/ql3-cluster-control': 1,
'deploy/containers/ql3-cluster-admin': 1,
'deploy/containers/ql3-local-application': 2,
'deploy/containers/ql3-worker': 1,
});
const DEPLOYMENT_ROOTS = Object.freeze([
'deploy/kubernetes/ql3-cluster',
'deploy/kubernetes/ql3-worker',
@@ -257,7 +263,7 @@ function auditReleaseVersionContract(rootInput = DEFAULT_ROOT) {
versionOccurrences(
dockerfile,
`org.opencontainers.image.version=\"${identity.version}\"`,
) !== 1
) !== CONTAINER_DOCKER_VERSION_LABEL_COUNTS[containerRoot]
) {
fail(`container Dockerfile release identity drifted: ${containerRoot}`);
}
@@ -9,6 +9,7 @@ OPERATOR_ID='@@OPERATOR_ID@@'
ARCHITECTURE='@@ARCHITECTURE@@'
SOURCE_REVISION='@@SOURCE_REVISION@@'
ARCHIVE='@@ARCHIVE@@'
VARIANT='@@VARIANT@@'
fail() {
printf '%s\n' "QingLong Local Alpha quickstart failed: $*" >&2
@@ -37,6 +38,18 @@ case "$profile" in
;;
*) usage ;;
esac
case "$VARIANT" in
headless)
network_mode=none
application_config=local-application.json
;;
console)
[ "$(uname -s)" = Linux ] || fail 'Console variant requires a Linux Docker host'
network_mode=host
application_config=local-api.json
;;
*) fail 'embedded Trial Kit variant is invalid' ;;
esac
case "$data_root" in
/|*[!A-Za-z0-9_./-]*|*'/../'*|*'/./'*|*'/..'|*'/.'|*'//'*|*/)
@@ -92,6 +105,11 @@ 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
if [ "$VARIANT" = console ]; then
cat >"$data_root/local-api.json" <<EOF
{"schema":"qinglong/local-api-process@v1","deploymentRoot":"/var/lib/qinglong3","applicationConfigFilePath":"/var/lib/qinglong3/local-application.json","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","listener":{"host":"127.0.0.1","port":5700}}
EOF
fi
chmod 0600 "$data_root"/*.json
uid=$(id -u)
@@ -129,12 +147,12 @@ 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 \
--restart unless-stopped --read-only --user "$uid:$gid" --network "$network_mode" \
--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)
"$APPLICATION_IMAGE" --config "/var/lib/qinglong3/$application_config")
printf '%s\n' "$container_id" >"$data_root/container.id"
chmod 0600 "$data_root/container.id"
@@ -153,10 +171,15 @@ done
umask "$old_umask"
printf '%s\n' \
"QingLong 3.0 Local Alpha is active ($profile, $ARCHITECTURE)." \
"QingLong 3.0 Local Alpha is active ($VARIANT, $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.'
if [ "$VARIANT" = console ]; then
printf '%s\n' \
'Console: http://127.0.0.1:5700/' \
'Remote access: create an SSH tunnel to 127.0.0.1:5700; do not expose the port on LAN or the public Internet.'
fi