fix: validate ARMv6 upgrades and publish rolling development bundles

This commit is contained in:
whyour
2026-10-21 01:43:44 +08:00
parent 77fe551425
commit b486989399
29 changed files with 977 additions and 201 deletions
+15 -27
View File
@@ -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"
+29 -2
View File
@@ -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);
+4
View File
@@ -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(' '));
+10 -2
View File
@@ -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,
+14 -4
View File
@@ -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
+183
View File
@@ -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);
},
);
+47 -3
View File
@@ -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 });
-46
View File
@@ -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/ /
-7
View File
@@ -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 -7
View File
@@ -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 域名。
+3
View File
@@ -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: '',
+41 -12
View File
@@ -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',
});
}
+3 -2
View File
@@ -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`),
];
+15 -3
View File
@@ -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
View File
@@ -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=$!
+3 -2
View File
@@ -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
+24
View File
@@ -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');
+72
View File
@@ -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);
});
+65 -3
View File
@@ -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 -1
View File
@@ -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};
+126 -27
View File
@@ -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 \'🔄'),
);
+55
View File
@@ -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 }));
+53 -34
View File
@@ -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'));
});
+162
View File
@@ -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/);
});