mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prove first usable automation journey
This commit is contained in:
@@ -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@v4';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v2';
|
||||
const SCHEMA = 'qinglong/alpha-local-trial-kit@v5';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v3';
|
||||
const QUICKSTART_TEMPLATE = path.join(
|
||||
DEFAULT_ROOT,
|
||||
'scripts/templates/ql3-local-alpha-quickstart.sh',
|
||||
@@ -40,6 +40,7 @@ const VERIFICATION = Object.freeze({
|
||||
operator128MiBEntrypoint: 'passed',
|
||||
operatorPackageInventory: 'passed',
|
||||
freshOwnerJourney: 'passed',
|
||||
ownerCredentialPresentation: 'passed',
|
||||
edgeFreshLifecycle: 'passed',
|
||||
standaloneFreshLifecycle: 'passed',
|
||||
localApiCancellation: 'passed',
|
||||
@@ -49,6 +50,7 @@ function verificationGates(variant) {
|
||||
return Object.freeze({
|
||||
...VERIFICATION,
|
||||
consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable',
|
||||
firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable',
|
||||
});
|
||||
}
|
||||
const WORKFLOW_IDENTITY = Object.freeze({
|
||||
@@ -302,7 +304,10 @@ 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(verificationGates(expected.variant))) ||
|
||||
!exactKeys(
|
||||
document.gates,
|
||||
Object.keys(verificationGates(expected.variant)),
|
||||
) ||
|
||||
JSON.stringify(document.gates) !==
|
||||
JSON.stringify(verificationGates(expected.variant))
|
||||
) {
|
||||
@@ -618,7 +623,7 @@ function createLocalAlphaTrialKit(options, adapters = {}) {
|
||||
0o700,
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
schema: SCHEMA,
|
||||
maturity: 'alpha_candidate_not_public_release',
|
||||
product: 'local',
|
||||
@@ -721,7 +726,7 @@ function auditLocalAlphaTrialKit(options) {
|
||||
'readme',
|
||||
'verification',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 5 ||
|
||||
manifest.schemaVersion !== 6 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
|
||||
manifest.product !== 'local' ||
|
||||
|
||||
@@ -90,8 +90,7 @@ function inspectImages(applicationImage, operatorImage, variant) {
|
||||
applicationLabels?.['io.qinglong.profile'] !== expectedProfile ||
|
||||
applicationLabels?.['io.qinglong.ai'] !== 'excluded' ||
|
||||
(variant === 'console'
|
||||
? applicationLabels?.['io.qinglong.local.console'] !==
|
||||
'offline-loopback'
|
||||
? 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' ||
|
||||
@@ -285,6 +284,34 @@ function establishFirstOwner(state) {
|
||||
) {
|
||||
fail('first Owner ceremony did not converge');
|
||||
}
|
||||
writePrivateJson(path.join(state.root, 'owner-credential-install.json'), {
|
||||
schemaVersion: 1,
|
||||
operation: 'owner.credential-presentation.install-from-delivery',
|
||||
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: state.profile,
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
request: {
|
||||
credentialMutationId,
|
||||
destinationFilePath: '/var/lib/qinglong3/owner-credential.json',
|
||||
},
|
||||
});
|
||||
const presentation = runOperator(
|
||||
state,
|
||||
'owner',
|
||||
'owner-credential-install.json',
|
||||
);
|
||||
if (
|
||||
presentation.status !== 'installed' ||
|
||||
presentation.credentialMutationId !== credentialMutationId
|
||||
) {
|
||||
fail('Owner credential presentation did not install');
|
||||
}
|
||||
for (const acknowledgement of [
|
||||
{
|
||||
file: 'owner-credential-ack.json',
|
||||
@@ -312,10 +339,68 @@ function establishFirstOwner(state) {
|
||||
provisioned: true,
|
||||
challenged: true,
|
||||
claimed: true,
|
||||
credentialPresentationInstalled: true,
|
||||
acknowledged: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createFirstAutomationTask(state) {
|
||||
if (state.variant !== 'console') {
|
||||
return Object.freeze({ status: 'not_applicable' });
|
||||
}
|
||||
writePrivateJson(path.join(state.root, 'alpha-first-task.json'), {
|
||||
schemaVersion: 1,
|
||||
operation: 'task.put',
|
||||
options: {
|
||||
deploymentRoot: '/var/lib/qinglong3',
|
||||
databasePath: '/var/lib/qinglong3/qinglong3.sqlite',
|
||||
profile: state.profile,
|
||||
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
|
||||
credentialFilePath: '/var/lib/qinglong3/owner-credential.json',
|
||||
busyTimeoutMs: 100,
|
||||
},
|
||||
request: {
|
||||
projectId: 'default',
|
||||
taskId: 'alpha-first-automation',
|
||||
expectedRevision: null,
|
||||
mutationId: '019f8680-143d-4000-8000-000000000031',
|
||||
requestId: 'alpha-trial-first-task',
|
||||
failureAuditEventId: '019f8680-143d-4000-8000-000000000032',
|
||||
name: 'QingLong 3.0 first automation',
|
||||
description:
|
||||
'A bounded offline task proving the fresh Alpha installation can execute work',
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/bin/echo',
|
||||
args: ['qinglong3-alpha-first-automation'],
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: { 'qinglong.alpha.example': 'true' },
|
||||
enabled: true,
|
||||
occurredAtMs: 1_785_254_400_031,
|
||||
},
|
||||
});
|
||||
const result = runOperator(state, 'task', 'alpha-first-task.json');
|
||||
if (
|
||||
result.status !== 'created' ||
|
||||
result.task?.taskId !== 'alpha-first-automation' ||
|
||||
result.task?.revision !== 1 ||
|
||||
result.task?.enabled !== true
|
||||
) {
|
||||
fail('first automation Task did not converge');
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'created',
|
||||
taskId: result.task.taskId,
|
||||
revision: result.task.revision,
|
||||
});
|
||||
}
|
||||
|
||||
function writeApplicationConfig(state) {
|
||||
writePrivateJson(path.join(state.root, 'local-application.json'), {
|
||||
schema: 'qinglong/local-application-process@v2',
|
||||
@@ -346,8 +431,7 @@ function writeApplicationConfig(state) {
|
||||
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',
|
||||
applicationConfigFilePath: '/var/lib/qinglong3/local-application.json',
|
||||
ownerPepperKeyringDirectory: '/var/lib/qinglong3/owner-peppers',
|
||||
listener: { host: '127.0.0.1', port: 5700 },
|
||||
});
|
||||
@@ -358,7 +442,39 @@ function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function consoleSurfaceContract() {
|
||||
function credentialToken(state) {
|
||||
const value = JSON.parse(
|
||||
fs.readFileSync(path.join(state.root, 'owner-credential.json'), 'utf8'),
|
||||
);
|
||||
if (
|
||||
value?.schemaVersion !== 1 ||
|
||||
value?.kind !== 'qinglong3-local-identity-credential-presentation' ||
|
||||
typeof value?.token !== 'string' ||
|
||||
!/^ql3c_own_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{43}$/u.test(value.token)
|
||||
) {
|
||||
fail('installed Owner credential presentation is invalid');
|
||||
}
|
||||
return value.token;
|
||||
}
|
||||
|
||||
async function apiRequest(pathname, token, options = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:5700${pathname}`, {
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
...options,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.body === undefined
|
||||
? {}
|
||||
: { 'content-type': 'application/json' }),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const body = await response.json();
|
||||
return Object.freeze({ status: response.status, body });
|
||||
}
|
||||
|
||||
async function consoleSurfaceContract(state) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
@@ -380,10 +496,81 @@ async function consoleSurfaceContract() {
|
||||
`Console HTTP contract drifted: root=${root.status}, unauthenticatedApi=${api.status}`,
|
||||
);
|
||||
}
|
||||
const token = credentialToken(state);
|
||||
const task = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation',
|
||||
token,
|
||||
);
|
||||
if (
|
||||
task.status !== 200 ||
|
||||
task.body?.task?.revision !== 1 ||
|
||||
!/^[0-9a-f]{64}$/u.test(task.body?.task?.contentDigest ?? '')
|
||||
) {
|
||||
fail(
|
||||
'starter Task is not visible through the authenticated Console API',
|
||||
);
|
||||
}
|
||||
const started = await apiRequest(
|
||||
'/api/v3/projects/default/tasks/alpha-first-automation/runs',
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
schema: 'qinglong/task-start@v1',
|
||||
mutationId: '019f8680-143d-7000-8000-000000000041',
|
||||
expectedRevision: task.body.task.revision,
|
||||
expectedContentDigest: task.body.task.contentDigest,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (
|
||||
started.status !== 202 ||
|
||||
started.body?.status !== 'accepted' ||
|
||||
typeof started.body?.runId !== 'string' ||
|
||||
typeof started.body?.attemptId !== 'string'
|
||||
) {
|
||||
fail('starter Task did not accept one fenced Run');
|
||||
}
|
||||
let terminal;
|
||||
for (let attempt = 0; attempt < 80; attempt += 1) {
|
||||
const current = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}`,
|
||||
token,
|
||||
);
|
||||
if (current.status !== 200) fail('starter Run became unreadable');
|
||||
if (
|
||||
['succeeded', 'failed', 'cancelled', 'timed_out'].includes(
|
||||
current.body?.run?.status,
|
||||
)
|
||||
) {
|
||||
terminal = current.body.run.status;
|
||||
break;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
if (terminal !== 'succeeded') {
|
||||
fail(`starter Run did not succeed: ${terminal ?? 'timeout'}`);
|
||||
}
|
||||
const log = await apiRequest(
|
||||
`/api/v3/projects/default/runs/${started.body.runId}/attempts/${started.body.attemptId}/log?offset=0&length=32768`,
|
||||
token,
|
||||
);
|
||||
const logText =
|
||||
log.status === 200 && log.body?.status === 'available'
|
||||
? Buffer.from(log.body.content, 'base64').toString('utf8')
|
||||
: '';
|
||||
if (!logText.includes('qinglong3-alpha-first-automation')) {
|
||||
fail('starter Run log does not contain the bounded work marker');
|
||||
}
|
||||
return Object.freeze({
|
||||
listener: '127.0.0.1:5700',
|
||||
rootStatus: 200,
|
||||
unauthenticatedApiStatus: 401,
|
||||
firstAutomation: Object.freeze({
|
||||
taskId: 'alpha-first-automation',
|
||||
runStatus: terminal,
|
||||
logMarkerObserved: true,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -458,7 +645,7 @@ async function runApplication(state) {
|
||||
surfacePromise = (async () => {
|
||||
try {
|
||||
if (state.variant === 'console') {
|
||||
surface = await consoleSurfaceContract();
|
||||
surface = await consoleSurfaceContract(state);
|
||||
}
|
||||
} catch (error) {
|
||||
surfaceError = error;
|
||||
@@ -542,6 +729,7 @@ async function main() {
|
||||
try {
|
||||
const setup = prepareFreshAuthority(state);
|
||||
const owner = establishFirstOwner(state);
|
||||
const firstAutomation = createFirstAutomationTask(state);
|
||||
writeApplicationConfig(state);
|
||||
const lifecycle = await runApplication(state);
|
||||
const database = new DatabaseSync(path.join(root, 'qinglong3.sqlite'), {
|
||||
@@ -565,8 +753,8 @@ async function main() {
|
||||
fail('durable SQLite result is invalid');
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schema: 'qinglong/local-alpha-trial-kit-live@v2',
|
||||
schemaVersion: 3,
|
||||
schema: 'qinglong/local-alpha-trial-kit-live@v3',
|
||||
variant: options.variant,
|
||||
profile: options.profile,
|
||||
architecture: images.architecture,
|
||||
@@ -576,6 +764,7 @@ async function main() {
|
||||
},
|
||||
setup,
|
||||
owner,
|
||||
firstAutomation,
|
||||
lifecycle,
|
||||
sqliteIntegrity: integrity,
|
||||
activeOwnerBindings: ownerCount,
|
||||
|
||||
@@ -102,10 +102,16 @@ 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/owner-credential-install.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"owner.credential-presentation.install-from-delivery","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":{"credentialMutationId":"019f8680-143d-4000-8000-000000000021","destinationFilePath":"/var/lib/qinglong3/owner-credential.json"}}
|
||||
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/alpha-first-task.json" <<EOF
|
||||
{"schemaVersion":1,"operation":"task.put","options":{"deploymentRoot":"/var/lib/qinglong3","databasePath":"/var/lib/qinglong3/qinglong3.sqlite","profile":"$profile","ownerPepperKeyringDirectory":"/var/lib/qinglong3/owner-peppers","credentialFilePath":"/var/lib/qinglong3/owner-credential.json","busyTimeoutMs":100},"request":{"projectId":"default","taskId":"alpha-first-automation","expectedRevision":null,"mutationId":"019f8680-143d-4000-8000-000000000031","requestId":"alpha-trial-first-task","failureAuditEventId":"019f8680-143d-4000-8000-000000000032","name":"QingLong 3.0 first automation","description":"A bounded offline task proving the fresh Alpha installation can execute work","kind":"command","spec":{"schema":"qinglong/command@v1","config":{"command":{"kind":"argv","file":"/bin/echo","args":["qinglong3-alpha-first-automation"]}}},"labels":{"qinglong.alpha.example":"true"},"enabled":true,"occurredAtMs":1785254400031}}
|
||||
EOF
|
||||
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
|
||||
@@ -136,6 +142,12 @@ grep -q '"status":"inserted"' "$data_root/results/owner-challenge.json.result.js
|
||||
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'
|
||||
run_operator owner owner-credential-install.json
|
||||
grep -q '"status":"installed"' "$data_root/results/owner-credential-install.json.result.json" || fail 'Owner credential presentation was not installed'
|
||||
if [ "$VARIANT" = console ]; then
|
||||
run_operator task alpha-first-task.json
|
||||
grep -q '"status":"created"' "$data_root/results/alpha-first-task.json.result.json" || fail 'first automation Task was not created'
|
||||
fi
|
||||
|
||||
ready=0
|
||||
cleanup() {
|
||||
@@ -174,6 +186,7 @@ printf '%s\n' \
|
||||
"QingLong 3.0 Local Alpha is active ($VARIANT, $profile, $ARCHITECTURE)." \
|
||||
"Data root: $data_root" \
|
||||
"Owner deliveries: $data_root/owner-delivery" \
|
||||
"Owner credential presentation: $data_root/owner-credential.json" \
|
||||
"Logs: docker logs $container_name" \
|
||||
"Stop: docker stop --time 30 $container_name" \
|
||||
"Remove container: docker rm $container_name" \
|
||||
@@ -181,5 +194,6 @@ printf '%s\n' \
|
||||
if [ "$VARIANT" = console ]; then
|
||||
printf '%s\n' \
|
||||
'Console: http://127.0.0.1:5700/' \
|
||||
'Starter Task: alpha-first-automation (review its revision/content fence in Console before running it).' \
|
||||
'Remote access: create an SSH tunnel to 127.0.0.1:5700; do not expose the port on LAN or the public Internet.'
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user