diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index aafc4ff2..3b0fb1ba 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -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" diff --git a/back/bootstrap/upgradeProbe.ts b/back/bootstrap/upgradeProbe.ts index 87f93782..bc546cc2 100644 --- a/back/bootstrap/upgradeProbe.ts +++ b/back/bootstrap/upgradeProbe.ts @@ -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 }, + 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); diff --git a/cli/scripts/build.cjs b/cli/scripts/build.cjs index 0226946f..d1a7fb0e 100644 --- a/cli/scripts/build.cjs +++ b/cli/scripts/build.cjs @@ -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')], diff --git a/cli/src/internal/maintenance/nodeRuntime.cjs b/cli/src/internal/maintenance/nodeRuntime.cjs new file mode 100644 index 00000000..c73d22cc --- /dev/null +++ b/cli/src/internal/maintenance/nodeRuntime.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(' ')); diff --git a/cli/src/internal/maintenance/operator.ts b/cli/src/internal/maintenance/operator.ts index be33e645..895638e4 100644 --- a/cli/src/internal/maintenance/operator.ts +++ b/cli/src/internal/maintenance/operator.ts @@ -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 { 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], diff --git a/cli/src/internal/maintenance/platformBundle.cjs b/cli/src/internal/maintenance/platformBundle.cjs index 4315ca34..be31be85 100644 --- a/cli/src/internal/maintenance/platformBundle.cjs +++ b/cli/src/internal/maintenance/platformBundle.cjs @@ -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, }); } diff --git a/cli/src/internal/maintenance/upgradePrestart.cjs b/cli/src/internal/maintenance/upgradePrestart.cjs index 6a26efbe..e73f5880 100644 --- a/cli/src/internal/maintenance/upgradePrestart.cjs +++ b/cli/src/internal/maintenance/upgradePrestart.cjs @@ -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, diff --git a/cli/src/internal/runtime/backendProcesses.ts b/cli/src/internal/runtime/backendProcesses.ts index 1d554d96..accf668f 100644 --- a/cli/src/internal/runtime/backendProcesses.ts +++ b/cli/src/internal/runtime/backendProcesses.ts @@ -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 diff --git a/cli/test/internal/armv6-startup.test.cjs b/cli/test/internal/armv6-startup.test.cjs new file mode 100644 index 00000000..09e88bee --- /dev/null +++ b/cli/test/internal/armv6-startup.test.cjs @@ -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); + }, +); diff --git a/cli/test/internal/backendProcesses.test.cjs b/cli/test/internal/backendProcesses.test.cjs index d34169d8..df1d6107 100644 --- a/cli/test/internal/backendProcesses.test.cjs +++ b/cli/test/internal/backendProcesses.test.cjs @@ -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 }); diff --git a/docker/Dockerfile.armv6-glibc b/docker/Dockerfile.armv6-glibc deleted file mode 100644 index e80c731f..00000000 --- a/docker/Dockerfile.armv6-glibc +++ /dev/null @@ -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"] diff --git a/docker/Dockerfile.upgrade-bundle-armv6-glibc b/docker/Dockerfile.upgrade-bundle-armv6-glibc deleted file mode 100644 index 0c073ac3..00000000 --- a/docker/Dockerfile.upgrade-bundle-armv6-glibc +++ /dev/null @@ -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/ / diff --git a/docker/armv6-run b/docker/armv6-run deleted file mode 100755 index 4d6a2ff7..00000000 --- a/docker/armv6-run +++ /dev/null @@ -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 / "$@" diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md index fc844b6e..d49f9a38 100644 --- a/docs/development/platform-upgrade-bundles.md +++ b/docs/development/platform-upgrade-bundles.md @@ -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--`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。 +开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v--`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。 -开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。 +开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。 + +开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。 版本入口为 GitHub prerelease `upgrade-v` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v--` 下的固定下载 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 域名。 diff --git a/ecosystem.config.js b/ecosystem.config.js index 6546ed4f..f910ae74 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -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: '', diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs index 7fc9b715..3dc64fd7 100644 --- a/scripts/publish-upgrade-bundles.cjs +++ b/scripts/publish-upgrade-bundles.cjs @@ -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', }); } diff --git a/scripts/verify-panel-package.cjs b/scripts/verify-panel-package.cjs index 40155f78..05ce9d6d 100644 --- a/scripts/verify-panel-package.cjs +++ b/scripts/verify-panel-package.cjs @@ -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`), ]; diff --git a/scripts/wait-for-mirror.sh b/scripts/wait-for-mirror.sh index 25f9befa..ab0ea25d 100644 --- a/scripts/wait-for-mirror.sh +++ b/scripts/wait-for-mirror.sh @@ -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 diff --git a/shell/share.sh b/shell/share.sh index 4a4f2379..50f8a1d0 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -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=$! diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index bc9bd871..22e07f42 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -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 diff --git a/test/back/mirror-status.test.cjs b/test/back/mirror-status.test.cjs index e0eec3fe..d9beff5a 100644 --- a/test/back/mirror-status.test.cjs +++ b/test/back/mirror-status.test.cjs @@ -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'); diff --git a/test/back/node-runtime.test.cjs b/test/back/node-runtime.test.cjs new file mode 100644 index 00000000..8a6e2363 --- /dev/null +++ b/test/back/node-runtime.test.cjs @@ -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); +}); diff --git a/test/back/platform-bundle.test.cjs b/test/back/platform-bundle.test.cjs index 9583baf5..10b1def3 100644 --- a/test/back/platform-bundle.test.cjs +++ b/test/back/platform-bundle.test.cjs @@ -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); diff --git a/test/back/primary-apm.test.cjs b/test/back/primary-apm.test.cjs index ab0e09e1..30bab063 100644 --- a/test/back/primary-apm.test.cjs +++ b/test/back/primary-apm.test.cjs @@ -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}; diff --git a/test/back/publish-upgrade-bundles.test.cjs b/test/back/publish-upgrade-bundles.test.cjs index c376dced..53581a89 100644 --- a/test/back/publish-upgrade-bundles.test.cjs +++ b/test/back/publish-upgrade-bundles.test.cjs @@ -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'), + ); +}); diff --git a/test/back/start-certificate-preparation.test.cjs b/test/back/start-certificate-preparation.test.cjs index 827600e2..3edd9c96 100644 --- a/test/back/start-certificate-preparation.test.cjs +++ b/test/back/start-certificate-preparation.test.cjs @@ -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 \'🔄'), ); diff --git a/test/back/static-mirror.test.cjs b/test/back/static-mirror.test.cjs index 47af860d..75085ea8 100644 --- a/test/back/static-mirror.test.cjs +++ b/test/back/static-mirror.test.cjs @@ -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 })); diff --git a/test/back/upgrade-bundle-workflow.test.cjs b/test/back/upgrade-bundle-workflow.test.cjs index c054c654..145b9077 100644 --- a/test/back/upgrade-bundle-workflow.test.cjs +++ b/test/back/upgrade-bundle-workflow.test.cjs @@ -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')); +}); diff --git a/test/back/upgrade-readiness.test.cjs b/test/back/upgrade-readiness.test.cjs new file mode 100644 index 00000000..3850baed --- /dev/null +++ b/test/back/upgrade-readiness.test.cjs @@ -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/); +});