mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 06:58:52 +08:00
fix: validate ARMv6 upgrades and publish rolling development bundles
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
||||
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
||||
- "cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||
- "!.github/workflows/cli-package.yml"
|
||||
branches:
|
||||
- "master"
|
||||
@@ -19,10 +20,6 @@ on:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_development_bundles:
|
||||
description: Develop only - build and publish shared develop/debian-dev upgrade packages
|
||||
type: boolean
|
||||
default: false
|
||||
static_sync_only:
|
||||
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||
type: boolean
|
||||
@@ -42,10 +39,10 @@ permissions:
|
||||
|
||||
# Keep master image tags and their bundle indexes in one serialized release.
|
||||
# A rerun may intentionally replace a version, including from the same commit.
|
||||
# Manual development bundle releases are serialized independently of master.
|
||||
# Ordinary develop pushes and Git-tag builds remain independent.
|
||||
# Develop image builds and their shared bundle index serialize separately.
|
||||
# Git-tag builds and static synchronization remain independent.
|
||||
concurrency:
|
||||
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }}
|
||||
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -116,14 +113,19 @@ jobs:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||
|
||||
- name: publish verified upgrade download sizes
|
||||
env:
|
||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||
run: |
|
||||
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||
|
||||
static_gitlab:
|
||||
needs: build-static
|
||||
@@ -657,8 +659,8 @@ jobs:
|
||||
fi
|
||||
|
||||
build-upgrade-bundles:
|
||||
# Development packages are opt-in; both channels use this run's image digests.
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
|
||||
# Both channels publish packages from this run's immutable image digests.
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: [build-alpine, build-debian]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
@@ -677,7 +679,6 @@ jobs:
|
||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||
- { platform: linux/arm/v6, libc: glibc, name: armv6-glibc }
|
||||
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||
@@ -692,7 +693,6 @@ jobs:
|
||||
env:
|
||||
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
@@ -701,18 +701,7 @@ jobs:
|
||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||
test -n "$digest"
|
||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
|
||||
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
|
||||
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
|
||||
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
|
||||
mkdir -p output/validator
|
||||
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
|
||||
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
|
||||
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
|
||||
else
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
fi
|
||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||
@@ -720,7 +709,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
publish-upgrade-bundles:
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
|
||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||
needs: build-upgrade-bundles
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -747,5 +736,4 @@ jobs:
|
||||
- name: Publish complete packages before advancing the channel
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }}
|
||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||
|
||||
@@ -4,6 +4,25 @@ import 'reflect-metadata';
|
||||
import dns from 'dns';
|
||||
import http from 'http';
|
||||
|
||||
// The first scheduler connection can outlive its short RPC deadline. Let the
|
||||
// normal single-flight recovery finish within the supervised startup budget.
|
||||
async function waitForUpgradeScheduler(
|
||||
readiness: { recover(): Promise<boolean> },
|
||||
timeoutMs: number,
|
||||
retryMs = 250,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await readiness.recover()) return;
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) break;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(retryMs, remaining)),
|
||||
);
|
||||
} while (Date.now() < deadline);
|
||||
throw new Error('Upgrade scheduler readiness failed');
|
||||
}
|
||||
|
||||
async function probeUpgrade() {
|
||||
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||
@@ -41,8 +60,16 @@ async function probeUpgrade() {
|
||||
config.grpcPort = await grpc.initialize();
|
||||
const { default: cronClient } = await import('../schedule/client');
|
||||
cronClient.readiness.configure(async () => {});
|
||||
if (!(await cronClient.readiness.recover()))
|
||||
throw new Error('Upgrade scheduler readiness failed');
|
||||
await waitForUpgradeScheduler(
|
||||
cronClient.readiness,
|
||||
process.arch === 'arm' &&
|
||||
Number(
|
||||
(process.config.variables as { arm_version?: string | number })
|
||||
.arm_version,
|
||||
) === 6
|
||||
? 60000
|
||||
: 10000,
|
||||
);
|
||||
const app = await createHttpApplication();
|
||||
loadExpress({ app });
|
||||
const listener = await server.initialize(app, 0);
|
||||
|
||||
@@ -21,6 +21,10 @@ fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'src/internal/maintenance/nodeRuntime.cjs'),
|
||||
path.join(root, 'dist/internal/maintenance/nodeRuntime.cjs'),
|
||||
);
|
||||
// The operational CLI may live outside the panel and has no panel node_modules.
|
||||
require('esbuild').buildSync({
|
||||
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
|
||||
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
|
||||
const armv6ServerArgs = Object.freeze([
|
||||
'--no-opt',
|
||||
'--no-sparkplug',
|
||||
'--no-maglev',
|
||||
]);
|
||||
function serverRuntimeArgs(runtime = process) {
|
||||
return runtime.arch === 'arm' &&
|
||||
Number(runtime.config.variables.arm_version) === 6 &&
|
||||
Number(runtime.versions.node.split('.')[0]) >= 24
|
||||
? [...armv6ServerArgs]
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = { serverRuntimeArgs, armv6ServerArgs };
|
||||
if (require.main === module)
|
||||
process.stdout.write(serverRuntimeArgs().join(' '));
|
||||
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
|
||||
import { atomicWrite } from '../runtime/files';
|
||||
import { runProcess, checkedProcess } from '../runtime/process';
|
||||
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
||||
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
|
||||
serverRuntimeArgs(): string[];
|
||||
armv6ServerArgs: readonly string[];
|
||||
};
|
||||
|
||||
export async function repairConfiguration(
|
||||
context: LocalContext,
|
||||
@@ -117,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
||||
if (match[3] !== entry) continue;
|
||||
if (
|
||||
match[3] !== entry &&
|
||||
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
|
||||
)
|
||||
continue;
|
||||
const pid = Number(match[1]);
|
||||
if (pid === process.pid) continue;
|
||||
try {
|
||||
@@ -179,7 +187,7 @@ export async function startPanel(
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const child = spawn(process.execPath, [entry], {
|
||||
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
|
||||
...options,
|
||||
detached: true,
|
||||
stdio: ['ignore', log.fd, log.fd],
|
||||
|
||||
@@ -6,6 +6,7 @@ const os = require('node:os');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const tar = require('tar');
|
||||
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||
const {
|
||||
prestartBackend,
|
||||
dependencyFailure,
|
||||
@@ -211,10 +212,12 @@ async function smokeDependencies(source, signal) {
|
||||
`;
|
||||
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
|
||||
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
||||
await execute(process.execPath, ['-e', probe], {
|
||||
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
|
||||
cwd: source,
|
||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||
timeout: 30000,
|
||||
// ARM1176 devices need the same startup allowance as the backend probe.
|
||||
// Keep this bounded and cancellable without forcing an unnecessary repair.
|
||||
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const crypto = require('node:crypto');
|
||||
const os = require('node:os');
|
||||
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||
|
||||
function probeLimits(target) {
|
||||
const small = target.arch === 'armv6';
|
||||
@@ -122,7 +123,7 @@ async function prestartBackend(
|
||||
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[`--max-old-space-size=${limits.heapMb}`, entry],
|
||||
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
|
||||
{
|
||||
cwd: workspace,
|
||||
env,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
const { armv6ServerArgs } = require('../maintenance/nodeRuntime.cjs') as {
|
||||
armv6ServerArgs: readonly string[];
|
||||
};
|
||||
|
||||
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
|
||||
// each process's cwd, not from the operator's cwd or a broad ps substring match.
|
||||
@@ -27,11 +30,18 @@ export async function findDirectBackendPids(
|
||||
);
|
||||
const args = command.split('\0');
|
||||
if (args.at(-1) === '') args.pop();
|
||||
// Both the legacy fallback and the TS fallback launch one script without
|
||||
// Node flags or application arguments. Reject other command shapes.
|
||||
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||
// Accept only the two launch shapes we own. Never treat eval, preload or
|
||||
// application arguments containing this path as a running backend.
|
||||
const scriptIndex =
|
||||
args.length === 2
|
||||
? 1
|
||||
: args.length === armv6ServerArgs.length + 2 &&
|
||||
armv6ServerArgs.every((flag, i) => args[i + 1] === flag)
|
||||
? args.length - 1
|
||||
: -1;
|
||||
if (scriptIndex < 0 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||
continue;
|
||||
const script = args[1]!;
|
||||
const script = args[scriptIndex]!;
|
||||
if (!script) continue;
|
||||
const absolute = path.isAbsolute(script)
|
||||
? script
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const vm = require('node:vm');
|
||||
const { createRequire } = require('node:module');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
|
||||
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
|
||||
|
||||
// Change only runtime selection; execute the compiled operator, actual child
|
||||
// Node and process discovery, without pretending the host CPU is ARM.
|
||||
async function operatorFor(arch, armVersion, node) {
|
||||
const filename = require.resolve('../../dist/internal/maintenance/operator');
|
||||
const localRequire = createRequire(filename);
|
||||
const module = { exports: {} };
|
||||
const flags = runtime.serverRuntimeArgs({
|
||||
arch,
|
||||
config: { variables: { arm_version: armVersion } },
|
||||
versions: { node },
|
||||
});
|
||||
vm.runInNewContext(
|
||||
await fs.readFile(filename, 'utf8'),
|
||||
{
|
||||
module,
|
||||
exports: module.exports,
|
||||
process,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
require(name) {
|
||||
return name === './nodeRuntime.cjs'
|
||||
? { ...runtime, serverRuntimeArgs: () => [...flags] }
|
||||
: localRequire(name);
|
||||
},
|
||||
},
|
||||
{ filename },
|
||||
);
|
||||
return { ...module.exports, flags };
|
||||
}
|
||||
|
||||
for (const [arch, arm, node] of [
|
||||
['arm', 6, '24.18.1'],
|
||||
['arm', 6, '20.18.1'],
|
||||
['arm', 6, '22.20.0'],
|
||||
['arm', 7, '24.18.1'],
|
||||
['arm64', undefined, '24.18.1'],
|
||||
['x64', undefined, '24.18.1'],
|
||||
])
|
||||
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
|
||||
const operator = await operatorFor(arch, arm, node);
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
|
||||
);
|
||||
const context = createContext(
|
||||
{ root },
|
||||
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
|
||||
);
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
let pid;
|
||||
t.after(async () => {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
await fs.writeFile(
|
||||
entry,
|
||||
`require('fs').writeFileSync(${JSON.stringify(
|
||||
path.join(root, 'runtime.json'),
|
||||
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
|
||||
);
|
||||
const started = await operator.startPanel(context);
|
||||
pid = started.pid;
|
||||
const report = JSON.parse(
|
||||
await fs.readFile(path.join(root, 'runtime.json')),
|
||||
);
|
||||
assert.equal(started.manager, 'node');
|
||||
assert.deepEqual(report.args, operator.flags);
|
||||
assert.equal(report.pid, pid);
|
||||
await operator.stopPanel(context);
|
||||
for (let n = 0; n < 100; n++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (e) {
|
||||
if (e.code === 'ESRCH') {
|
||||
pid = undefined;
|
||||
break;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(
|
||||
pid,
|
||||
undefined,
|
||||
'the exact backend must stop even with Node flags',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
|
||||
{
|
||||
skip: process.platform !== 'linux',
|
||||
timeout: 20000,
|
||||
},
|
||||
async (t) => {
|
||||
const operator = await operatorFor('arm', 6, '24.18.1');
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
|
||||
);
|
||||
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
const incoming = path.join(root, 'incoming.js'),
|
||||
journal = path.join(root, 'journal.jsonl');
|
||||
const record = (version) =>
|
||||
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
|
||||
journal,
|
||||
)},JSON.stringify({version:${JSON.stringify(
|
||||
version,
|
||||
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
await fs.mkdir(context.data, { recursive: true });
|
||||
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
|
||||
await fs.writeFile(entry, record('old'));
|
||||
await fs.writeFile(incoming, record('new'));
|
||||
const pids = [];
|
||||
t.after(async () => {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
pids.push((await operator.startPanel(context)).pid);
|
||||
await assert.rejects(
|
||||
replaceAndReload(context, [{ source: incoming, target: entry }], {
|
||||
stop: (ctx) => operator.stopPanel(ctx),
|
||||
start: async (ctx, recover) => {
|
||||
const result = await operator.startPanel(ctx);
|
||||
pids.push(result.pid);
|
||||
if (!recover) throw new Error('injected new-version health failure');
|
||||
},
|
||||
}),
|
||||
/injected new-version health failure/,
|
||||
);
|
||||
const launches = (await fs.readFile(journal, 'utf8'))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse);
|
||||
assert.deepEqual(
|
||||
launches.map((x) => x.version),
|
||||
['old', 'new', 'old'],
|
||||
);
|
||||
for (const row of launches) assert.deepEqual(row.args, operator.flags);
|
||||
for (const row of launches.slice(0, -1))
|
||||
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
|
||||
process.kill(launches.at(-1).pid, 0);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, '.env'), 'utf8'),
|
||||
'KEEP=custom\n',
|
||||
);
|
||||
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
|
||||
'old data',
|
||||
);
|
||||
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
|
||||
assert.ok(
|
||||
!(await fs.readdir(path.dirname(entry))).some((x) =>
|
||||
x.includes('ql-backup'),
|
||||
),
|
||||
);
|
||||
await operator.stopPanel(context);
|
||||
},
|
||||
);
|
||||
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
|
||||
const {
|
||||
findDirectBackendPids,
|
||||
} = require('../../dist/internal/runtime/backendProcesses');
|
||||
|
||||
test('backend discovery accepts only owned compatibility flags and the exact installation entry', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-flagged-proc-')),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const proc = path.join(root, 'proc'),
|
||||
panel = path.join(root, 'panel with spaces');
|
||||
const other = path.join(root, 'other/static/build/app.js');
|
||||
const entry = path.join(panel, 'static/build/app.js');
|
||||
for (const file of [entry, other]) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
await fs.writeFile(file, '');
|
||||
}
|
||||
const flags = ['--no-opt', '--no-sparkplug', '--no-maglev'];
|
||||
const cases = [
|
||||
[['/usr/bin/node', ...flags, entry], true],
|
||||
[['node24', ...flags, 'static/build/app.js'], true],
|
||||
[['node', ...flags, other], false],
|
||||
[['node', '-e', entry], false],
|
||||
[['node', '-r', entry], false],
|
||||
[['node', ...flags, entry, 'argument'], false],
|
||||
[['node', '--no-opt', entry], false],
|
||||
[['bash', ...flags, entry], false],
|
||||
[['node', ...flags, '-e', entry], false],
|
||||
];
|
||||
const expected = [];
|
||||
for (const [i, [args, matches]] of cases.entries()) {
|
||||
const pid = 910001 + i,
|
||||
directory = path.join(proc, String(pid));
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
|
||||
await fs.symlink(panel, path.join(directory, 'cwd'));
|
||||
if (matches) expected.push(pid);
|
||||
}
|
||||
assert.deepEqual((await findDirectBackendPids(entry, proc)).sort(), expected);
|
||||
});
|
||||
|
||||
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
|
||||
const root = await fs.realpath(
|
||||
@@ -64,7 +103,10 @@ test(
|
||||
const { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const {
|
||||
stopPanel,
|
||||
startPanel,
|
||||
} = require('../../dist/internal/maintenance/operator');
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
|
||||
const children = [];
|
||||
let fallbackPid;
|
||||
@@ -223,7 +265,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const { createContext } = require('../../dist/internal/runtime/context');
|
||||
const { startPanel } = require('../../dist/internal/maintenance/operator');
|
||||
const {
|
||||
startPanel,
|
||||
} = require('../../dist/internal/maintenance/operator');
|
||||
const context = createContext({ root }, { PATH: '', QL_LANG: language });
|
||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# ARMv7 is a source of application files only. None of its system libraries or
|
||||
# Node binaries are executed or copied into the ARMv6 runtime.
|
||||
ARG SOURCE_IMAGE
|
||||
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
|
||||
|
||||
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
|
||||
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
|
||||
# access checks. Pin the upstream static tracer instead of misresolving paths.
|
||||
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
|
||||
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
|
||||
chmod 755 /usr/local/bin/proot && proot --version
|
||||
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
|
||||
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
|
||||
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
|
||||
debootstrap --foreign --arch=armhf --variant=minbase \
|
||||
--keyring=/usr/share/keyrings/raspbian.gpg \
|
||||
bullseye /armv6 https://archive.raspbian.org/raspbian
|
||||
COPY docker/armv6-run /usr/local/bin/armv6-run
|
||||
RUN chmod 755 /usr/local/bin/armv6-run && \
|
||||
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
|
||||
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
|
||||
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
|
||||
mkdir -p /armv6/etc/ssl/certs && \
|
||||
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
|
||||
printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \
|
||||
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
|
||||
chmod 755 /armv6/usr/sbin/policy-rc.d && \
|
||||
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
|
||||
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
|
||||
# Build against the minimum supported Node, including its bundled C++ headers.
|
||||
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
|
||||
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
|
||||
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
|
||||
COPY --from=source /ql/ /armv6/ql/
|
||||
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
|
||||
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
|
||||
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
|
||||
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
|
||||
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
|
||||
ENTRYPOINT ["/usr/local/bin/armv6-run"]
|
||||
@@ -1,13 +0,0 @@
|
||||
# An immutable digest of the CI-only ARM1176 validator, built in the same run.
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE} AS bundle
|
||||
ARG BASE_IMAGE
|
||||
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
|
||||
RUN ulimit -c 0; armv6-run /bin/bash -ec \
|
||||
'node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out; \
|
||||
for archive in /out/*.tar.gz; do \
|
||||
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install; \
|
||||
rm -rf /tmp/ql-bundle-offline-smoke; \
|
||||
done'
|
||||
FROM scratch
|
||||
COPY --from=bundle /armv6/out/ /
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176
|
||||
# Bind only kernel interfaces; all application files and writable state remain
|
||||
# inside this container's ARMv6 root. Child processes also pass through QEMU.
|
||||
exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \
|
||||
-r /armv6 -b /proc -b /dev -w / "$@"
|
||||
@@ -8,15 +8,17 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
||||
|
||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||
|
||||
正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行:
|
||||
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
|
||||
|
||||
```sh
|
||||
gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true
|
||||
gh workflow run build-docker-image.yml --ref develop
|
||||
```
|
||||
|
||||
手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。
|
||||
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
||||
|
||||
开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。
|
||||
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||
|
||||
开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。
|
||||
|
||||
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
||||
|
||||
@@ -62,7 +64,7 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
|
||||
|
||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||
|
||||
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
|
||||
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||
|
||||
## npm 面板包
|
||||
|
||||
@@ -87,9 +89,8 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,
|
||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
|
||||
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||
|
||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const isContainer = process.env.QL_CONTAINER === 'true';
|
||||
const runtimeArgs =
|
||||
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
@@ -18,6 +20,7 @@ module.exports = {
|
||||
// visible through `docker logs` before Winston is initialized.
|
||||
error_file: isContainer ? '/proc/1/fd/2' : undefined,
|
||||
script: 'static/build/app.js',
|
||||
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
|
||||
env: {
|
||||
http_proxy: '',
|
||||
https_proxy: '',
|
||||
|
||||
@@ -13,7 +13,6 @@ const platforms = [
|
||||
'linux-s390x-musl',
|
||||
'linux-ia32-musl',
|
||||
'linux-x64-glibc',
|
||||
'linux-armv6-glibc',
|
||||
'linux-armv7-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-ppc64-glibc',
|
||||
@@ -61,20 +60,14 @@ function publishBundles({
|
||||
branch,
|
||||
version,
|
||||
buildId,
|
||||
allowDevelopment = false,
|
||||
gh = (...args) =>
|
||||
execFileSync('gh', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}),
|
||||
}) {
|
||||
if (
|
||||
branch !== 'master' &&
|
||||
!(branch === 'develop' && allowDevelopment === true)
|
||||
)
|
||||
throw new Error(
|
||||
'Only master or explicitly requested development images publish bundles',
|
||||
);
|
||||
if (!['master', 'develop'].includes(branch))
|
||||
throw new Error('Only master or develop images publish bundles');
|
||||
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
|
||||
throw new Error('Invalid bundle version');
|
||||
if (!/^\d+-\d+$/.test(buildId || ''))
|
||||
@@ -226,6 +219,45 @@ function publishBundles({
|
||||
'--clobber',
|
||||
);
|
||||
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
||||
if (
|
||||
!matches(
|
||||
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
||||
path.join(directory, 'latest.json'),
|
||||
)
|
||||
)
|
||||
throw new Error('Published channel index verification failed');
|
||||
}
|
||||
// Only prune development releases after the complete new batch and its
|
||||
// channel pointer are verified. Production versions and aliases are retained.
|
||||
if (development && currentSource()) {
|
||||
const pages = JSON.parse(
|
||||
gh(
|
||||
'api',
|
||||
`repos/${repository}/releases?per_page=100`,
|
||||
'--paginate',
|
||||
'--slurp',
|
||||
),
|
||||
);
|
||||
for (const release of pages.flat()) {
|
||||
if (
|
||||
release.tag_name !== tag &&
|
||||
/^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test(
|
||||
release.tag_name,
|
||||
) &&
|
||||
release.prerelease === true &&
|
||||
release.draft === false
|
||||
) {
|
||||
if (!currentSource()) break;
|
||||
gh(
|
||||
'release',
|
||||
'delete',
|
||||
release.tag_name,
|
||||
'--repo',
|
||||
repository,
|
||||
'--yes',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -246,8 +278,5 @@ if (require.main === module) {
|
||||
branch,
|
||||
version,
|
||||
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||
allowDevelopment:
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
|
||||
process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ const requiredFiles = [
|
||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||
'sample/config.sample.sh', 'sample/auth.sample.json',
|
||||
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
||||
'static/build/bootstrap/upgradeProbe.js', 'static/dist/index.html',
|
||||
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade']
|
||||
'static/build/bootstrap/upgradeProbe.js',
|
||||
'static/dist/index.html',
|
||||
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime']
|
||||
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
||||
];
|
||||
|
||||
|
||||
@@ -47,7 +47,19 @@ fi
|
||||
for requested_ref in "${refs[@]}"; do
|
||||
git check-ref-format "$requested_ref"
|
||||
done
|
||||
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
|
||||
# Ubuntu 26.04's default timeout is from uutils. We require GNU's process-group
|
||||
# termination so git transport children cannot keep the query pipes open.
|
||||
timeout_command=''
|
||||
for candidate in timeout gnutimeout gtimeout; do
|
||||
if version=$("$candidate" --version 2>/dev/null) && [[ "$version" == *'(GNU coreutils)'* ]]; then
|
||||
timeout_command=$candidate
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$timeout_command" ]]; then
|
||||
echo 'GNU timeout is required (timeout, gnutimeout or gtimeout)' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$notify_repo" ]]; then
|
||||
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
|
||||
@@ -62,7 +74,7 @@ notification_command() {
|
||||
local budget=$((notification_deadline - SECONDS))
|
||||
if (( budget <= 0 )); then return 1; fi
|
||||
if (( budget > 10 )); then budget=10; fi
|
||||
timeout --signal=KILL "${budget}s" "$@"
|
||||
"$timeout_command" --signal=KILL "${budget}s" "$@"
|
||||
}
|
||||
|
||||
notify_static_mirror() {
|
||||
@@ -156,7 +168,7 @@ while (( SECONDS < wait_seconds )); do
|
||||
fi
|
||||
budget=$query_timeout
|
||||
if (( budget > remaining )); then budget=$remaining; fi
|
||||
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
|
||||
if ! output=$("$timeout_command" --signal=KILL "${budget}s" git ls-remote --refs \
|
||||
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
||||
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
|
||||
ready=false
|
||||
|
||||
+15
-2
@@ -294,6 +294,18 @@ delete_pm2() {
|
||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||
}
|
||||
|
||||
load_node_runtime_args() {
|
||||
ql_node_runtime_args=()
|
||||
local runtime_args
|
||||
local helper="$dir_root/cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||
if [[ -f "$helper" ]]; then
|
||||
runtime_args=$(node "$helper") || return $?
|
||||
if [[ -n "$runtime_args" ]]; then
|
||||
read -r -a ql_node_runtime_args <<<"$runtime_args"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
reload_pm2() {
|
||||
cd "$dir_root" || return $?
|
||||
restore_env_vars
|
||||
@@ -305,6 +317,7 @@ reload_pm2() {
|
||||
local exit_code=$?
|
||||
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
|
||||
t '正在尝试直接使用 Node.js 启动服务...'
|
||||
load_node_runtime_args || return $?
|
||||
|
||||
# Kill any existing node processes for qinglong
|
||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||
@@ -312,9 +325,9 @@ reload_pm2() {
|
||||
# Start node directly in the background. In containers, keep application
|
||||
# output on the container streams instead of duplicating Winston logs on disk.
|
||||
if [[ "$QL_CONTAINER" == "true" ]]; then
|
||||
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||
else
|
||||
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||
fi
|
||||
local node_pid=$!
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ fix_config
|
||||
|
||||
# Slow devices may need more than the worker's readiness window to generate
|
||||
# first-boot certificates. Reuse the regular initializer before starting workers.
|
||||
node <<'QL_PREPARE_GRPC_CERTS'
|
||||
load_node_runtime_args
|
||||
node "${ql_node_runtime_args[@]}" <<'QL_PREPARE_GRPC_CERTS'
|
||||
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
|
||||
.initGrpcCerts()
|
||||
.catch((error) => {
|
||||
@@ -63,7 +64,7 @@ require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcC
|
||||
});
|
||||
QL_PREPARE_GRPC_CERTS
|
||||
|
||||
pm2 l &>/dev/null
|
||||
pm2 l &>/dev/null || true
|
||||
|
||||
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
|
||||
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
||||
|
||||
@@ -50,6 +50,30 @@ test('mirror verification reads matching refs without changing either repository
|
||||
assert.equal(git('-C', source, 'status', '--porcelain'), '');
|
||||
});
|
||||
|
||||
test('mirror verification selects GNU timeout when the default is another implementation', (t) => {
|
||||
const { tmp, source, mirror } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
const realTimeout = ['timeout', 'gnutimeout', 'gtimeout'].find((name) => {
|
||||
const result = spawnSync(name, ['--version'], { encoding: 'utf8' });
|
||||
return result.status === 0 && result.stdout.includes('(GNU coreutils)');
|
||||
});
|
||||
assert.ok(realTimeout, 'The mirror script requires GNU timeout');
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.symlinkSync(execFileSync('which', [realTimeout], { encoding: 'utf8' }).trim(), path.join(bin, 'gnutimeout'));
|
||||
fs.symlinkSync(execFileSync('which', ['git'], { encoding: 'utf8' }).trim(), path.join(bin, 'git'));
|
||||
fs.symlinkSync(execFileSync('which', ['sort'], { encoding: 'utf8' }).trim(), path.join(bin, 'sort'));
|
||||
fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nif [ "$1" = --version ]; then echo "timeout (uutils coreutils)"; exit 0; fi\nexit 98\n', { mode: 0o755 });
|
||||
const args = [script, source, mirror, 'develop'];
|
||||
const result = spawnSync('/bin/bash', args, options({ PATH: bin }));
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||
fs.unlinkSync(path.join(bin, 'gnutimeout'));
|
||||
const unsupported = spawnSync('/bin/bash', args, options({ PATH: bin }));
|
||||
assert.equal(unsupported.status, 2, unsupported.stderr + unsupported.stdout);
|
||||
assert.match(unsupported.stderr, /GNU timeout is required/);
|
||||
});
|
||||
|
||||
test('mirror polling follows a source that advances while waiting', async (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
serverRuntimeArgs,
|
||||
} = require('../../cli/src/internal/maintenance/nodeRuntime.cjs');
|
||||
|
||||
test('server compatibility flags target ARMv6 Node 24 while retaining WebAssembly and the existing Node', () => {
|
||||
for (const [arch, armVersion, version, enabled] of [
|
||||
['arm', '6', '24.18.1', true],
|
||||
['arm', 6, '24.18.1', true],
|
||||
['arm', 6, '20.18.1', false],
|
||||
['arm', 6, '22.20.0', false],
|
||||
['arm', 7, '24.18.1', false],
|
||||
['arm64', undefined, '24.18.1', false],
|
||||
['x64', undefined, '24.18.1', false],
|
||||
]) {
|
||||
const flags = serverRuntimeArgs({
|
||||
arch,
|
||||
config: { variables: { arm_version: armVersion } },
|
||||
versions: { node: version },
|
||||
});
|
||||
assert.deepEqual(
|
||||
flags,
|
||||
enabled ? ['--no-opt', '--no-sparkplug', '--no-maglev'] : [],
|
||||
);
|
||||
assert.ok(!flags.includes('--jitless'));
|
||||
}
|
||||
});
|
||||
|
||||
test('the normal PM2 application uses the same server arguments as prestart', () => {
|
||||
const app = require('../../ecosystem.config.js').apps.find(
|
||||
(x) => x.name === 'qinglong',
|
||||
);
|
||||
const flags = serverRuntimeArgs();
|
||||
assert.deepEqual(app.node_args || [], flags);
|
||||
if (!flags.length) assert.equal(app.node_args, undefined);
|
||||
});
|
||||
|
||||
test('ARMv6 server flags retain real HTTP, fetch and undici parsing', () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const flags = serverRuntimeArgs({
|
||||
arch: 'arm',
|
||||
config: { variables: { arm_version: 6 } },
|
||||
versions: { node: '24.18.1' },
|
||||
});
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
...flags,
|
||||
'-e',
|
||||
`
|
||||
const http=require('http'),assert=require('assert/strict');
|
||||
assert.equal(typeof WebAssembly,'object');
|
||||
const server=http.createServer((req,res)=>res.end('parser-ok'));
|
||||
server.listen(0,'127.0.0.1',async()=>{
|
||||
try {
|
||||
const url='http://127.0.0.1:'+server.address().port;
|
||||
assert.equal(await (await fetch(url)).text(),'parser-ok');
|
||||
assert.equal(await (await require('undici').request(url)).body.text(),'parser-ok');
|
||||
} catch(e){console.error(e);process.exitCode=1;}
|
||||
finally{server.closeAllConnections();server.close();}
|
||||
});
|
||||
`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, NODE_OPTIONS: '' },
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr || result.error?.message);
|
||||
});
|
||||
@@ -7,6 +7,67 @@ const tar = require('tar');
|
||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||
const artifacts = require('../../shell/upgrade.cjs');
|
||||
|
||||
test('dependency probes allow slow ARMv6 startup while preserving cancellation and normal Node execution', async () => {
|
||||
const vm = require('node:vm');
|
||||
const filename = require.resolve(
|
||||
'../../cli/src/internal/maintenance/platformBundle.cjs',
|
||||
);
|
||||
const text = await fs.readFile(filename, 'utf8');
|
||||
for (const [arch, armVersion, version, timeout] of [
|
||||
['arm', 6, '20.18.1', 300000],
|
||||
['arm', 6, '24.18.1', 300000],
|
||||
['arm', 7, '24.18.1', 30000],
|
||||
['x64', undefined, '24.18.1', 30000],
|
||||
]) {
|
||||
let launch;
|
||||
const m = { exports: {} };
|
||||
const controller = new AbortController();
|
||||
const localRequire = (name) => {
|
||||
if (name === 'node:child_process')
|
||||
return {
|
||||
execFile(program, args, options, callback) {
|
||||
launch = { program, args, options };
|
||||
callback(null, '', '');
|
||||
},
|
||||
};
|
||||
return require(require.resolve(name, {
|
||||
paths: [path.dirname(filename)],
|
||||
}));
|
||||
};
|
||||
vm.runInNewContext(
|
||||
text,
|
||||
{
|
||||
module: m,
|
||||
exports: m.exports,
|
||||
require: localRequire,
|
||||
process: {
|
||||
arch,
|
||||
config: { variables: { arm_version: armVersion } },
|
||||
versions: { node: version },
|
||||
execPath: process.execPath,
|
||||
env: { NODE_OPTIONS: '--jitless', NODE_PATH: '/outside' },
|
||||
},
|
||||
},
|
||||
{ filename },
|
||||
);
|
||||
await m.exports.smokeDependencies('/staged/payload', controller.signal);
|
||||
assert.equal(launch.program, process.execPath);
|
||||
assert.equal(launch.args.at(-2), '-e');
|
||||
assert.deepEqual(
|
||||
Array.from(launch.args.slice(0, -2)),
|
||||
arch === 'arm' && armVersion === 6 && version.startsWith('24.')
|
||||
? ['--no-opt', '--no-sparkplug', '--no-maglev']
|
||||
: [],
|
||||
);
|
||||
assert.ok(!launch.args.includes('--jitless'));
|
||||
assert.equal(launch.options.cwd, '/staged/payload');
|
||||
assert.equal(launch.options.timeout, timeout);
|
||||
assert.equal(launch.options.signal, controller.signal);
|
||||
assert.equal(launch.options.env.NODE_OPTIONS, '');
|
||||
assert.equal(launch.options.env.NODE_PATH, '');
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ['prestart', 'repair'])
|
||||
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
@@ -395,6 +456,7 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
|
||||
bundle.assertCompatible(manifest, target, '20.20.2');
|
||||
const glibcTarget = {
|
||||
...target,
|
||||
arch: 'armv7',
|
||||
libc: 'glibc',
|
||||
libcVersion: '2.31',
|
||||
endianness: 'LE',
|
||||
@@ -405,7 +467,7 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
|
||||
target: { ...glibcTarget, libcMinimum: '2.31' },
|
||||
};
|
||||
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
|
||||
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
|
||||
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv7-glibc');
|
||||
assert.throws(
|
||||
() =>
|
||||
bundle.assertCompatible(glibcManifest, {
|
||||
@@ -529,10 +591,10 @@ test('publishing requires every platform and validates all immutable archive has
|
||||
);
|
||||
}
|
||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
assert.equal(Object.keys(index.packages).length, 12);
|
||||
const armv6 = path.join(
|
||||
f.directory,
|
||||
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
|
||||
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
|
||||
);
|
||||
const armv6Bytes = await fs.readFile(armv6);
|
||||
await fs.rm(armv6);
|
||||
|
||||
@@ -2,11 +2,12 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const configRequire = require('node:module').createRequire(require('node:path').resolve('ecosystem.config.js'));
|
||||
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
|
||||
const appSource = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const ts = require('typescript');
|
||||
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
|
||||
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
|
||||
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env},require:configRequire});return module.exports.apps[0];}
|
||||
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
||||
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
|
||||
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
|
||||
|
||||
@@ -69,6 +69,15 @@ function fixture(t) {
|
||||
const route = args[1];
|
||||
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
||||
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
||||
if (route.endsWith('/releases?per_page=100'))
|
||||
return JSON.stringify([
|
||||
Array.from(state.releases.keys(), (tag_name) => ({
|
||||
tag_name,
|
||||
prerelease: true,
|
||||
draft: false,
|
||||
...state.releaseMetadata?.[tag_name],
|
||||
})),
|
||||
]);
|
||||
if (route.includes('/releases/tags/')) {
|
||||
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
|
||||
if (state.lookupFailure === tag)
|
||||
@@ -118,13 +127,23 @@ function fixture(t) {
|
||||
digest: `sha256:${sha256(bytes)}`,
|
||||
});
|
||||
}
|
||||
if (tag === 'upgrade-develop' && state.corruptChannel)
|
||||
release.get('latest.json').digest = 'sha256:wrong';
|
||||
if (files.length > 1) {
|
||||
if (state.corruptUploadedBatch)
|
||||
release.get(path.basename(files[0])).digest = 'sha256:wrong';
|
||||
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
|
||||
if (state.changeDevelopHeadAfterUpload)
|
||||
state.developHead = 'd'.repeat(40);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'delete') {
|
||||
if (state.failDelete) throw new Error('Delete unavailable');
|
||||
assert.ok(state.releases.has(args[2]));
|
||||
state.releases.delete(args[2]);
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'release' && args[1] === 'edit') return '';
|
||||
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
|
||||
};
|
||||
@@ -143,10 +162,10 @@ test('publishes every platform and installer before advancing version and legacy
|
||||
assert.equal(index.version, '2.24.0');
|
||||
assert.equal(index.buildId, '100-1');
|
||||
assert.equal(index.sourceCommit, f.options.commit);
|
||||
assert.equal(Object.keys(index.packages).length, 13);
|
||||
assert.equal(Object.keys(index.packages).length, 12);
|
||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||
assert.equal(f.state.releases.get(tag).size, 28);
|
||||
assert.equal(f.state.releases.get(tag).size, 26);
|
||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||
const url = new URL(entry.url),
|
||||
filename = path.basename(url.pathname);
|
||||
@@ -230,7 +249,7 @@ for (const fault of ['missing', 'corrupt', 'wrong-version'])
|
||||
const f = fixture(t);
|
||||
const archive = path.join(
|
||||
f.directory,
|
||||
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
|
||||
`qinglong-${f.options.commit}-linux-armv6-musl.tar.gz`,
|
||||
);
|
||||
if (fault === 'missing') fs.rmSync(archive);
|
||||
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
|
||||
@@ -288,7 +307,7 @@ test('an explicitly rerun earlier workflow may replace the same version and comm
|
||||
|
||||
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
|
||||
for (const branch of ['debian-dev', 'v2.24.0', 'feature/test'])
|
||||
assert.throws(() => f.publish({ branch }), /Only master/);
|
||||
for (const buildId of [undefined, '100', '100-1/other'])
|
||||
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
|
||||
@@ -316,7 +335,7 @@ test('existing master clients can migrate from indexes without version/build met
|
||||
assert.equal(f.index().buildId, '100-2');
|
||||
});
|
||||
|
||||
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
||||
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
@@ -325,18 +344,17 @@ test('explicit development publication shares musl/glibc packages without changi
|
||||
f.publish({
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const development = f.index('upgrade-develop');
|
||||
assert.equal(development.channel, 'develop');
|
||||
assert.equal(development.sourceCommit, f.state.developHead);
|
||||
assert.equal(Object.keys(development.packages).length, 13);
|
||||
assert.equal(Object.keys(development.packages).length, 12);
|
||||
assert.ok(development.packages['linux-x64-musl']);
|
||||
assert.ok(development.packages['linux-x64-glibc']);
|
||||
assert.ok(development.packages['linux-armv6-musl']);
|
||||
assert.ok(development.packages['linux-armv6-glibc']);
|
||||
assert.equal(development.packages['linux-armv6-glibc'], undefined);
|
||||
for (const entry of [
|
||||
...Object.values(development.packages),
|
||||
development.installer,
|
||||
@@ -344,44 +362,30 @@ test('explicit development publication shares musl/glibc packages without changi
|
||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 28);
|
||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
|
||||
assert.equal(
|
||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
||||
28,
|
||||
26,
|
||||
);
|
||||
});
|
||||
|
||||
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
|
||||
test('development builds cannot publish obsolete development source', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const allowDevelopment of [false, undefined, 'true'])
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'develop', allowDevelopment }),
|
||||
/explicitly requested/,
|
||||
);
|
||||
assert.throws(
|
||||
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
|
||||
/Only master/,
|
||||
);
|
||||
assert.equal(f.state.calls.length, 0);
|
||||
f.write(f.state.developHead, 'development');
|
||||
const obsolete = f.state.developHead;
|
||||
f.state.developHead = 'd'.repeat(40);
|
||||
assert.equal(
|
||||
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.publish({ branch: 'develop', commit: obsolete }), false);
|
||||
assert.equal(f.state.releases.size, 0);
|
||||
});
|
||||
|
||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
|
||||
test(`failed automatic development publication preserves both channels (${fault})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
const options = {
|
||||
branch: 'develop',
|
||||
commit: f.state.developHead,
|
||||
allowDevelopment: true,
|
||||
};
|
||||
f.write(options.commit, 'first development');
|
||||
f.publish(options);
|
||||
@@ -396,3 +400,98 @@ for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
});
|
||||
|
||||
for (const fault of [
|
||||
null,
|
||||
'failBatchUpload',
|
||||
'corruptUploadedBatch',
|
||||
'corruptChannel',
|
||||
])
|
||||
test(`development retention deletes old batches only after verified publication (${
|
||||
fault || 'success'
|
||||
})`, (t) => {
|
||||
const f = fixture(t);
|
||||
f.publish();
|
||||
const production = f.index();
|
||||
const options = { branch: 'develop', commit: f.state.developHead };
|
||||
f.write(options.commit, 'development');
|
||||
f.publish(options);
|
||||
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
|
||||
// Never prune production, channel aliases, legacy/unclassified releases,
|
||||
// drafts or a non-prerelease even when its name resembles a dev build.
|
||||
const protectedTags = [
|
||||
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
|
||||
'v2.24.0',
|
||||
'upgrade-build-develop-v2.24.0-10-1',
|
||||
'upgrade-build-develop-v2.24.0-11-1',
|
||||
];
|
||||
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
|
||||
f.state.releaseMetadata = {
|
||||
[protectedTags[2]]: { draft: true },
|
||||
[protectedTags[3]]: { prerelease: false },
|
||||
};
|
||||
f.write(options.commit, 'next development');
|
||||
if (fault) f.state[fault] = true;
|
||||
if (fault)
|
||||
assert.throws(
|
||||
() => f.publish({ ...options, buildId: '101-1' }),
|
||||
/Upload interrupted|verification failed/,
|
||||
);
|
||||
else {
|
||||
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
|
||||
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||
const deletion = f.state.calls.findIndex(
|
||||
(a) => a[0] === 'release' && a[1] === 'delete',
|
||||
);
|
||||
const aliasEdit = f.state.calls.findLastIndex(
|
||||
(a) => a[0] === 'release' && a[1] === 'edit',
|
||||
);
|
||||
assert.ok(deletion > aliasEdit);
|
||||
assert.ok(
|
||||
f.state.calls
|
||||
.slice(aliasEdit + 1, deletion)
|
||||
.some(
|
||||
(a) =>
|
||||
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
|
||||
),
|
||||
);
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
||||
}
|
||||
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
|
||||
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
|
||||
assert.deepEqual(f.index(), production);
|
||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||
});
|
||||
|
||||
test('failed development cleanup is retryable without overwriting immutable assets', (t) => {
|
||||
const f = fixture(t);
|
||||
const options = { branch: 'develop', commit: f.state.developHead };
|
||||
f.write(options.commit, 'development');
|
||||
f.publish(options);
|
||||
f.state.failDelete = true;
|
||||
assert.throws(
|
||||
() => f.publish({ ...options, buildId: '101-1' }),
|
||||
/Delete unavailable/,
|
||||
);
|
||||
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||
f.state.failDelete = false;
|
||||
f.publish({ ...options, buildId: '101-1' });
|
||||
assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
||||
});
|
||||
|
||||
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
||||
const f = fixture(t);
|
||||
const options = { branch: 'develop', commit: f.state.developHead };
|
||||
f.write(options.commit, 'development');
|
||||
f.publish(options);
|
||||
const before = f.index('upgrade-develop');
|
||||
f.state.changeDevelopHeadAfterUpload = true;
|
||||
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
|
||||
assert.deepEqual(f.index('upgrade-develop'), before);
|
||||
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||
assert.ok(
|
||||
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,9 @@ const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
||||
const preparation = startup.slice(
|
||||
const runtimeLoader = fs.readFileSync('shell/share.sh', 'utf8')
|
||||
.match(/^load_node_runtime_args\(\) \{[\s\S]*?^\}/m)[0];
|
||||
const preparation = `${runtimeLoader}\ndir_root="$QL_DIR"\n` + startup.slice(
|
||||
startup.indexOf('\nfix_config\n'),
|
||||
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,61 @@ const os = require('node:os');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||
|
||||
test('both static publication steps supply a token as the HTTPS password and reject missing credentials', () => {
|
||||
const workflow = require('js-yaml').load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
for (const name of [
|
||||
'copy to static repo',
|
||||
'publish verified upgrade download sizes',
|
||||
]) {
|
||||
const step = workflow.jobs['build-static'].steps.find(
|
||||
(step) => step.name === name,
|
||||
);
|
||||
assert.equal(step.env.STATIC_TOKEN, '${{ secrets.API_TOKEN }}');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-auth-'));
|
||||
try {
|
||||
// Capture the real workflow command's argv, without contacting any remote.
|
||||
for (const program of ['bash', 'node']) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, program),
|
||||
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QA_ARGS"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
const argsFile = path.join(tmp, 'args');
|
||||
const env = {
|
||||
PATH: tmp,
|
||||
QA_ARGS: argsFile,
|
||||
STATIC_TOKEN: 'fixture-secret',
|
||||
GITHUB_REPO: 'github.com/fixture/static',
|
||||
GITHUB_BRANCH: 'develop',
|
||||
GITHUB_REF_TYPE: 'branch',
|
||||
GITHUB_SHA: 'a'.repeat(40),
|
||||
};
|
||||
const result = spawnSync('/bin/bash', ['-euc', step.run], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const remote = fs.readFileSync(argsFile, 'utf8').split('\n')[1];
|
||||
const url = new URL(remote);
|
||||
assert.equal(url.username, 'x-access-token');
|
||||
assert.equal(url.password, env.STATIC_TOKEN);
|
||||
fs.unlinkSync(argsFile);
|
||||
assert.notEqual(
|
||||
spawnSync('/bin/bash', ['-euc', step.run], {
|
||||
env: { ...env, STATIC_TOKEN: '' },
|
||||
}).status,
|
||||
0,
|
||||
);
|
||||
assert.equal(fs.existsSync(argsFile), false);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
@@ -7,6 +7,14 @@ const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
|
||||
test('shared server runtime changes trigger the image and upgrade verification workflow', () => {
|
||||
const paths = workflow.on.push.paths;
|
||||
assert.ok(
|
||||
paths.indexOf('cli/src/internal/maintenance/nodeRuntime.cjs') >
|
||||
paths.indexOf('!cli/**'),
|
||||
);
|
||||
});
|
||||
// These conditions use GitHub's boolean operators and format(), which have the
|
||||
// same semantics for the boolean/string contexts exercised here.
|
||||
const evaluate = (expression, github, inputs = {}) => {
|
||||
@@ -25,25 +33,14 @@ const evaluate = (expression, github, inputs = {}) => {
|
||||
);
|
||||
};
|
||||
|
||||
test('both bundle jobs require master or an explicit development dispatch', () => {
|
||||
assert.equal(
|
||||
workflow.on.workflow_dispatch.inputs.build_development_bundles.default,
|
||||
false,
|
||||
);
|
||||
test('both channels automatically build bundles, with static-only and Git-tag runs excluded', () => {
|
||||
const cases = [
|
||||
['push', 'refs/heads/develop', {}, false],
|
||||
['push', 'refs/heads/develop', { build_development_bundles: true }, false],
|
||||
['workflow_dispatch', 'refs/heads/develop', {}, false],
|
||||
['push', 'refs/heads/develop', {}, true],
|
||||
['workflow_dispatch', 'refs/heads/develop', {}, true],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true },
|
||||
true,
|
||||
],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true, static_sync_only: true },
|
||||
{ static_sync_only: true },
|
||||
false,
|
||||
],
|
||||
['push', 'refs/heads/master', {}, true],
|
||||
@@ -55,12 +52,7 @@ test('both bundle jobs require master or an explicit development dispatch', () =
|
||||
false,
|
||||
],
|
||||
['push', 'refs/tags/v2.24.0', {}, false],
|
||||
[
|
||||
'workflow_dispatch',
|
||||
'refs/tags/v2.24.0',
|
||||
{ build_development_bundles: true },
|
||||
false,
|
||||
],
|
||||
['workflow_dispatch', 'refs/tags/v2.24.0', {}, false],
|
||||
];
|
||||
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
|
||||
for (const [event_name, ref, inputs, expected] of cases)
|
||||
@@ -84,26 +76,53 @@ test('both bundle jobs require master or an explicit development dispatch', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('manual development releases serialize separately while normal develop CI remains independent', () => {
|
||||
test('development image and bundle publication serialize separately from production and static-only runs', () => {
|
||||
const group = (event_name, ref, inputs = {}, run_id = '100') =>
|
||||
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
|
||||
const master = group('push', 'refs/heads/master');
|
||||
const development = group('workflow_dispatch', 'refs/heads/develop', {
|
||||
build_development_bundles: true,
|
||||
});
|
||||
const development = group('push', 'refs/heads/develop');
|
||||
assert.equal(
|
||||
development,
|
||||
group(
|
||||
'workflow_dispatch',
|
||||
'refs/heads/develop',
|
||||
{ build_development_bundles: true },
|
||||
'101',
|
||||
),
|
||||
group('workflow_dispatch', 'refs/heads/develop', {}, '101'),
|
||||
);
|
||||
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
|
||||
assert.notEqual(master, development);
|
||||
const push = group('push', 'refs/heads/develop');
|
||||
assert.notEqual(push, development);
|
||||
assert.notEqual(push, group('push', 'refs/heads/develop', {}, '101'));
|
||||
assert.notEqual(
|
||||
development,
|
||||
group('workflow_dispatch', 'refs/heads/develop', {
|
||||
static_sync_only: true,
|
||||
}),
|
||||
);
|
||||
assert.notEqual(
|
||||
group('push', 'refs/tags/v2.24.0'),
|
||||
group('push', 'refs/tags/v2.24.0', {}, '101'),
|
||||
);
|
||||
assert.equal(workflow.concurrency['cancel-in-progress'], false);
|
||||
});
|
||||
|
||||
test('bundle targets exactly match the source image platforms and publisher requirements', () => {
|
||||
const targets =
|
||||
workflow.jobs['build-upgrade-bundles'].strategy.matrix.include;
|
||||
for (const [job, libc] of [
|
||||
['build-alpine', 'musl'],
|
||||
['build-debian', 'glibc'],
|
||||
]) {
|
||||
const platforms = workflow.jobs[job].steps
|
||||
.find((step) => step.with?.platforms)
|
||||
.with.platforms.split(',');
|
||||
assert.deepEqual(
|
||||
targets
|
||||
.filter((t) => t.libc === libc)
|
||||
.map((t) => t.platform)
|
||||
.sort(),
|
||||
platforms.sort(),
|
||||
);
|
||||
}
|
||||
const { platforms } = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||
assert.deepEqual(
|
||||
targets.map((t) => `linux-${t.name}`).sort(),
|
||||
[...platforms].sort(),
|
||||
);
|
||||
assert.ok(platforms.includes('linux-armv6-musl'));
|
||||
assert.ok(!platforms.includes('linux-armv6-glibc'));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = load('back/shared/schedulerReadiness.ts');
|
||||
const { outputText } = ts.transpileModule(
|
||||
fs.readFileSync('back/bootstrap/upgradeProbe.ts', 'utf8'),
|
||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } },
|
||||
);
|
||||
|
||||
// Run the real probe entry point, including its lifecycle and IPC reporting.
|
||||
// Only external services and the clock are replaced; no test exports are needed.
|
||||
async function runProbe({ readiness, arch = 'x64', armVersion, supervised = true }) {
|
||||
let now = 0;
|
||||
const events = [], waits = [], messages = [];
|
||||
const build = { sourceCommit: 'a'.repeat(40) };
|
||||
let finish;
|
||||
const completed = new Promise((resolve) => { finish = resolve; });
|
||||
const listener = { address: () => ({ port: 12345 }) };
|
||||
const server = {
|
||||
initialize: async () => { events.push('http'); return listener; },
|
||||
shutdown: async () => { events.push('http-close'); },
|
||||
};
|
||||
const grpc = {
|
||||
initialize: async () => { events.push('grpc'); return 12346; },
|
||||
getServer: () => ({ forceShutdown: () => events.push('grpc-close') }),
|
||||
};
|
||||
class GrpcServerService {}
|
||||
class HttpServerService {}
|
||||
const modules = {
|
||||
'reflect-metadata': {},
|
||||
dns: { setDefaultResultOrder: () => {} },
|
||||
'../config': {},
|
||||
'../data': { sequelize: {
|
||||
transaction: async (action) => action({}),
|
||||
query: async () => [[{ value: 42 }]],
|
||||
close: async () => { events.push('db-close'); },
|
||||
} },
|
||||
'../loaders/db': async () => { events.push('db'); },
|
||||
typedi: { Container: { get: (service) => service === GrpcServerService ? grpc : server } },
|
||||
'../services/grpc': { GrpcServerService },
|
||||
'../services/http': { HttpServerService },
|
||||
'../loaders/httpApplication': { createHttpApplication: async () => ({}) },
|
||||
'../loaders/server': async () => {},
|
||||
'../loaders/express': () => {},
|
||||
'../loaders/app': {},
|
||||
'../loaders/depInjector': () => {},
|
||||
'../schedule/client': { readiness: {
|
||||
configure: (restore) => readiness.configure(restore),
|
||||
recover: async () => {
|
||||
const ready = await readiness.recover();
|
||||
if (ready) events.push('ready');
|
||||
return ready;
|
||||
},
|
||||
} },
|
||||
http: { get: (url, respond) => {
|
||||
events.push('health');
|
||||
const request = new EventEmitter();
|
||||
request.setTimeout = () => {};
|
||||
queueMicrotask(() => {
|
||||
const response = new EventEmitter();
|
||||
response.statusCode = 200;
|
||||
respond(response);
|
||||
response.emit('data', JSON.stringify({ code: 200, data: { status: 'ok', services: { grpc: true, http: true }, build } }));
|
||||
response.emit('end');
|
||||
});
|
||||
return request;
|
||||
} },
|
||||
};
|
||||
vm.runInNewContext(outputText, {
|
||||
exports: {},
|
||||
require: (name) => {
|
||||
assert.ok(Object.hasOwn(modules, name), `Unexpected probe dependency: ${name}`);
|
||||
return modules[name];
|
||||
},
|
||||
process: {
|
||||
env: { QL_UPGRADE_PROBE: supervised ? '1' : '0' }, arch,
|
||||
config: { variables: { arm_version: armVersion } }, pid: 123,
|
||||
once: () => {}, memoryUsage: () => ({}),
|
||||
send: (message) => { messages.push(message); events.push('report'); },
|
||||
exit: finish,
|
||||
},
|
||||
Date: { now: () => now },
|
||||
setTimeout: (callback, ms) => {
|
||||
assert.ok(ms > 0 && ms <= 250);
|
||||
waits.push(ms);
|
||||
now += ms;
|
||||
queueMicrotask(callback);
|
||||
},
|
||||
console: { error: () => {} },
|
||||
}, { filename: 'upgradeProbe.js' });
|
||||
const exitCode = await completed;
|
||||
return { exitCode, events, waits, messages, elapsed: now, build };
|
||||
}
|
||||
|
||||
test('upgrade probe survives cold scheduler recovery before checking HTTP and reporting readiness', async () => {
|
||||
let attempts = 0, restored = false;
|
||||
const readiness = new SchedulerReadiness(async () => {
|
||||
if (++attempts < 3) throw new Error('Failed to connect before the deadline');
|
||||
});
|
||||
// The probe must configure and finish restoration before reporting readiness.
|
||||
const configure = readiness.configure.bind(readiness);
|
||||
readiness.configure = (restore) => configure(async () => { await restore(); restored = true; });
|
||||
const result = await runProbe({ readiness });
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.equal(restored, true);
|
||||
assert.equal(await readiness.check(), true);
|
||||
assert.deepEqual(result.waits, [250, 250]);
|
||||
assert.deepEqual(result.events, ['db', 'grpc', 'ready', 'http', 'health', 'report', 'http-close', 'grpc-close', 'db-close']);
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].ok, true);
|
||||
assert.equal(result.messages[0].build.sourceCommit, result.build.sourceCommit);
|
||||
});
|
||||
|
||||
for (const [arch, armVersion, timeout] of [['x64', undefined, 10000], ['arm', 7, 10000], ['arm', '6', 60000]]) {
|
||||
test(`unavailable ${arch}/v${armVersion} scheduler fails at its startup budget and cleans up`, async () => {
|
||||
let attempts = 0;
|
||||
const result = await runProbe({ arch, armVersion, readiness: {
|
||||
configure: () => {}, recover: async () => { attempts++; return false; },
|
||||
} });
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(result.elapsed, timeout);
|
||||
assert.equal(attempts, timeout / 250);
|
||||
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].ok, false);
|
||||
assert.match(result.messages[0].message, /Upgrade scheduler readiness failed/);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(attempts, timeout / 250, 'no retries after failure');
|
||||
});
|
||||
}
|
||||
|
||||
test('unexpected scheduler recovery errors fail prestart without retries or a success report', async () => {
|
||||
let attempts = 0;
|
||||
const result = await runProbe({ readiness: {
|
||||
configure: () => {}, recover: async () => { attempts++; throw new Error('broken scheduler configuration'); },
|
||||
} });
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.deepEqual(result.waits, []);
|
||||
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
||||
assert.equal(result.messages.length, 1);
|
||||
assert.equal(result.messages[0].ok, false);
|
||||
assert.match(result.messages[0].message, /broken scheduler configuration/);
|
||||
});
|
||||
|
||||
test('an already ready scheduler reaches health without waiting', async () => {
|
||||
const result = await runProbe({ readiness: { configure: () => {}, recover: async () => true } });
|
||||
assert.equal(result.exitCode, 0);
|
||||
assert.deepEqual(result.waits, []);
|
||||
assert.equal(result.messages[0].ok, true);
|
||||
});
|
||||
|
||||
test('the upgrade probe still requires its isolated supervisor', async () => {
|
||||
const result = await runProbe({ supervised: false });
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.deepEqual(result.events, ['report']);
|
||||
assert.match(result.messages[0].message, /requires its isolated supervisor/);
|
||||
});
|
||||
Reference in New Issue
Block a user