diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index eb05f9c0..63f3e9af 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -8,6 +8,8 @@ on: # CLI is verified separately; its shared Bash upgrade engine ships in images. - "!cli/**" - "cli/src/internal/maintenance/upgradeArtifacts.cjs" + - "cli/src/internal/maintenance/platformBundle.cjs" + - "cli/src/internal/maintenance/panelUpgrade.cjs" - "!.github/workflows/cli-package.yml" branches: - "master" @@ -203,6 +205,8 @@ jobs: build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static + outputs: + image-digest: ${{ steps.image.outputs.digest }} runs-on: ubuntu-22.04 permissions: packages: write @@ -268,6 +272,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Build and push (Alpine) + id: image uses: docker/build-push-action@v7 with: build-args: | @@ -289,6 +294,8 @@ jobs: build-debian: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static + outputs: + image-digest: ${{ steps.image.outputs.digest }} runs-on: ubuntu-22.04 permissions: packages: write @@ -353,6 +360,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Build and push (Debian) + id: image uses: docker/build-push-action@v7 with: build-args: | @@ -563,3 +571,93 @@ jobs: echo '::error::Could not check the published npm version; refusing to publish.' exit 1 fi + + build-upgrade-bundles: + needs: [build-alpine, build-debian] + runs-on: ubuntu-22.04 + timeout-minutes: 90 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - { platform: linux/amd64, libc: musl, name: x64-musl } + - { platform: linux/arm/v6, libc: musl, name: armv6-musl } + - { platform: linux/arm/v7, libc: musl, name: armv7-musl } + - { platform: linux/arm64, libc: musl, name: arm64-musl } + - { platform: linux/ppc64le, libc: musl, name: ppc64-musl } + - { 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 } + - { platform: linux/s390x, libc: glibc, name: s390x-glibc } + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v4 + with: + cache-image: false + - uses: docker/setup-buildx-action@v4 + - name: Build and probe the immutable platform package + 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: | + set -euo pipefail + digest="$ALPINE_DIGEST" + 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 + - uses: actions/upload-artifact@v6 + with: + name: upgrade-${{ matrix.name }}-${{ github.sha }} + path: output/upgrade/* + if-no-files-found: error + + publish-upgrade-bundles: + needs: build-upgrade-bundles + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: upgrade-channel-${{ github.ref_name }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - run: pnpm install --prod --frozen-lockfile + - run: npm --prefix cli ci + - uses: actions/download-artifact@v7 + with: + pattern: upgrade-*-${{ github.sha }} + path: output/upgrade + merge-multiple: true + - run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs + - name: Publish complete packages before advancing the channel + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" diff --git a/back/app.ts b/back/app.ts index 8a195c00..0851a195 100644 --- a/back/app.ts +++ b/back/app.ts @@ -41,11 +41,18 @@ class Application { } private startMasterProcess() { + const readyTimeout = + process.arch === 'arm' && + Number( + (process.config.variables as { arm_version?: string | number }).arm_version, + ) === 6 + ? 120000 + : 30000; // Fork gRPC worker first and wait for it to be ready const grpcWorker = this.forkWorker('grpc'); // Wait for gRPC worker to signal it's ready before starting HTTP worker - this.waitForWorkerReady(grpcWorker, 30000) + this.waitForWorkerReady(grpcWorker, readyTimeout) .then(() => { Logger.info('[boot] gRPC worker is ready, starting HTTP worker'); this.httpWorker = this.forkWorker('http'); @@ -71,7 +78,7 @@ class Application { Logger.warn('Unable to notify HTTP worker of scheduler exit'); } const newGrpcWorker = this.forkWorker('grpc'); - this.waitForWorkerReady(newGrpcWorker, 30000) + this.waitForWorkerReady(newGrpcWorker, readyTimeout) .then(() => { Logger.info('gRPC worker restarted and ready'); // Re-register cron jobs by notifying the HTTP worker diff --git a/cli/package-lock.json b/cli/package-lock.json index 1f8e5034..8ddc7b09 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@whyour/qinglong-cli", "version": "0.1.2", + "license": "Apache-2.0", "bin": { "ql": "dist/npm/ql.js" }, @@ -14,12 +15,12 @@ "@types/node": "22.19.0", "commander": "15.0.0", "esbuild": "0.28.2", + "tar": "7.5.20", "typescript": "5.2.2" }, "engines": { "node": ">=22.12.0" - }, - "license": "Apache-2.0" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", @@ -463,6 +464,19 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/node": { "version": "22.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", @@ -473,6 +487,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -525,6 +549,46 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -545,6 +609,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } } } } diff --git a/cli/package.json b/cli/package.json index 7185a111..a8c931de 100644 --- a/cli/package.json +++ b/cli/package.json @@ -26,7 +26,8 @@ "commander": "15.0.0", "esbuild": "0.28.2", "typescript": "5.2.2", - "@types/node": "22.19.0" + "@types/node": "22.19.0", + "tar": "7.5.20" }, "license": "Apache-2.0", "repository": { diff --git a/cli/scripts/build.cjs b/cli/scripts/build.cjs index 0d4d4d92..0226946f 100644 --- a/cli/scripts/build.cjs +++ b/cli/scripts/build.cjs @@ -17,6 +17,21 @@ fs.copyFileSync( path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'), path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'), ); +fs.copyFileSync( + path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'), + path.join(root, 'dist/internal/maintenance/panelUpgrade.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')], + outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + minify: true, + legalComments: 'inline', +}); require('esbuild').buildSync({ entryPoints: [path.join(root, 'src/shared/cli/commander.ts')], outfile: path.join(root, 'dist/shared/cli/commander.js'), diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 72749b72..27d3b345 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -6,6 +6,7 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); const artifacts = require('./upgradeArtifacts.cjs'); +const { nodeRuntimeArgs } = require('./platformBundle.cjs'); const active = new Set(['starting', 'running', 'restarting']); const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); @@ -200,6 +201,63 @@ async function getStatus(tmp) { return state; } +// A shell/CLI restart can consume a bundle prepared by the offline installer +// or panel. Keep that existing job honest without creating jobs for legacy +// reloads which have no panel readiness record. +async function withPreparedJob(tmp, env, selected, operation) { + const previous = await getStatus(tmp); + if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。'); + if (previous.status !== 'ready') return operation(); + return artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + async () => { + const current = await getStatus(tmp); + if (active.has(current.status)) + throw new Error('已有更新或重载正在执行。'); + if (current.status !== 'ready') return operation(); + assertSelected(current, await stagedMetadata(selected)); + const previousBuild = await readRunningBuild(env).catch(() => undefined); + const { log, ...record } = current; + let state = { + ...record, + id: randomUUID(), + action: 'apply', + status: 'running', + phase: 'verifying', + startedAt: Date.now(), + pid: process.pid, + }; + const report = async (patch) => { + state = { ...state, ...patch, updatedAt: Date.now() }; + await save(tmp, state); + }; + await fs.writeFile( + logFile(tmp), + 'Manual application of prepared update\n', + { + mode: 0o600, + }, + ); + await report({}); + try { + const result = await operation({ + onStop: () => report({ status: 'restarting', phase: 'restarting' }), + onStart: (recover) => + recover + ? undefined + : verifyRunningVersion(env, state, previousBuild), + }); + await report({ status: 'completed', phase: 'completed' }); + return result; + } catch (error) { + await report({ status: 'failed', error: error.message }); + throw error; + } + }, + env, + ); +} + async function startJob({ root, tmp, env = process.env }, action) { if (!['download', 'apply'].includes(action)) throw new Error('Invalid upgrade action'); @@ -220,12 +278,14 @@ async function startJob({ root, tmp, env = process.env }, action) { let selected; let metadata; if (action === 'apply') { - // Fail before launching a process or stopping the working panel. + // Keep requests short on slow devices. The detached worker performs + // complete file/dependency verification before it can stop the panel. try { selected = await artifacts.selectedUpgrade( tmp, artifacts.releaseBranch(env), env, + { metadataOnly: true }, ); metadata = await stagedMetadata(selected); if (previous.status === 'ready') @@ -390,7 +450,7 @@ async function runJob({ root, tmp, env = process.env }, id) { ); await artifacts.waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); if (!recover) @@ -429,16 +489,30 @@ async function runJob({ root, tmp, env = process.env }, id) { } } -module.exports = { getStatus, startJob, runJob }; +module.exports = { + getStatus, + saveState: save, + startJob, + runJob, + withPanelLifecycle, + readRunningBuild, + verifyRunningVersion, + stagedMetadata, + withPreparedJob, +}; async function launchWorker(id) { // detached creates a session but keeps the original parent. PM2 kills the // backend's entire descendant tree, so an intermediate launcher must exit // before the worker is allowed to stop that backend. - const child = spawn(process.execPath, [__filename, 'worker', id], { - env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, - detached: true, - stdio: 'inherit', - }); + const child = spawn( + process.execPath, + [...nodeRuntimeArgs(), __filename, 'worker', id], + { + env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, + detached: true, + stdio: 'inherit', + }, + ); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); diff --git a/cli/src/internal/maintenance/platformBundle.cjs b/cli/src/internal/maintenance/platformBundle.cjs new file mode 100644 index 00000000..6c296cc8 --- /dev/null +++ b/cli/src/internal/maintenance/platformBundle.cjs @@ -0,0 +1,480 @@ +// One installed verifier handles release downloads and local offline archives. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const os = require('node:os'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const tar = require('tar'); +const execute = promisify(execFile); +// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep +// upgrade workers interpreted without changing the installed Node or server. +const nodeRuntimeArgs = (runtime = process) => + runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6 + ? ['--jitless'] + : []; +const sha256 = (bytes) => + crypto.createHash('sha256').update(bytes).digest('hex'); +const inside = (root, filename) => { + const relative = path.relative(root, filename); + return ( + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +}; +const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number); +function atLeast(actual, minimum) { + const a = versionParts(actual), + b = versionParts(minimum); + return ( + b.every((n) => Number.isFinite(n)) && + a.every((n) => Number.isFinite(n)) && + (a[0] > b[0] || + (a[0] === b[0] && + (a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0))))) + ); +} + +async function runtimeTarget() { + let libc, libcVersion; + if (process.platform === 'linux') { + const header = process.report.getReport().header; + if (header.glibcVersionRuntime) { + libc = 'glibc'; + libcVersion = header.glibcVersionRuntime; + } else { + let text = ''; + try { + text = (await execute('ldd', ['--version'])).stdout; + } catch (error) { + text = `${error.stdout || ''}\n${error.stderr || ''}`; + } + const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i); + if (!match) + throw new Error( + 'Cannot identify Linux libc; refusing a native dependency bundle', + ); + libc = 'musl'; + libcVersion = match[1]; + } + } + const armVersion = + process.arch === 'arm' + ? Number(process.config.variables.arm_version) + : undefined; + if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion)) + throw new Error('Cannot identify ARM instruction set'); + const arch = + process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch; + const armFloatAbi = + process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined; + if ( + process.arch === 'arm' && + !['hard', 'soft', 'softfp'].includes(armFloatAbi) + ) + throw new Error('Cannot identify ARM floating-point ABI'); + return { + platform: process.platform, + arch, + libc, + libcVersion, + endianness: os.endianness(), + armFloatAbi, + }; +} + +function targetKey(target) { + return [target.platform, target.arch, target.libc].filter(Boolean).join('-'); +} + +function assertCompatible(manifest, target, node = process.versions.node) { + if ( + manifest.format !== 2 || + !/^[a-f0-9]{40}$/.test(manifest.sourceCommit) || + !/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) || + !manifest.files || + typeof manifest.files !== 'object' || + Array.isArray(manifest.files) || + !Number.isSafeInteger(manifest.nodeApi) || + manifest.nodeApi < 6 + ) + throw new Error('Invalid platform bundle manifest'); + if (targetKey(manifest.target || {}) !== targetKey(target)) + throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`); + if ( + !atLeast(node, manifest.nodeMinimum) || + Number(process.versions.napi) < manifest.nodeApi + ) + throw new Error( + `Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`, + ); + if ( + manifest.target.endianness !== target.endianness || + manifest.target.armFloatAbi !== target.armFloatAbi + ) + throw new Error( + 'Bundle native byte order or ARM floating-point ABI mismatch', + ); + if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum)) + throw new Error( + `Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`, + ); +} + +async function inventory(roots) { + const files = {}; + async function visit(directory, prefix) { + for (const name of (await fs.readdir(directory)).sort()) { + const filename = path.join(directory, name), + relative = `${prefix}/${name}`; + const stat = await fs.lstat(filename); + if (stat.isDirectory()) await visit(filename, relative); + else if (stat.isFile()) + files[relative] = { + type: 'file', + mode: stat.mode & 0o777, + sha256: sha256(await fs.readFile(filename)), + }; + else if (stat.isSymbolicLink()) { + const link = await fs.readlink(filename); + const boundary = roots[prefix.split('/')[0]]; + if ( + path.isAbsolute(link) || + !inside(boundary, path.resolve(path.dirname(filename), link)) || + !inside(await fs.realpath(boundary), await fs.realpath(filename)) + ) + throw new Error(`Bundle link escapes its payload: ${relative}`); + files[relative] = { type: 'link', link }; + } else throw new Error(`Unsupported bundle entry: ${relative}`); + } + } + for (const [name, root] of Object.entries(roots)) await visit(root, name); + return files; +} + +async function verifyPayload(selected, manifest, target) { + assertCompatible(manifest, target); + for (const name of ['data', '.tmp', '.git', '.env', 'static']) { + if ( + await fs.lstat(path.join(selected.source, name)).catch((error) => { + if (error.code !== 'ENOENT') throw error; + return null; + }) + ) + throw new Error(`Reserved installation path in bundle: ${name}`); + } + const actual = await inventory({ + source: selected.source, + static: selected.static, + }); + const names = Object.keys(actual).sort(), + expected = Object.keys(manifest.files).sort(); + if ( + JSON.stringify(names) !== JSON.stringify(expected) || + names.some( + (name) => + JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]), + ) + ) + throw new Error( + 'Platform bundle files or dependency checksums do not match', + ); + if ( + sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !== + manifest.lockfileSha256 + ) + throw new Error('Platform bundle lockfile mismatch'); + await fs.access( + path.join(selected.source, 'node_modules/sqlite3/package.json'), + ); +} + +async function smokeDependencies(source, signal) { + // This probe is installed verifier code, never a verifier supplied by an archive. + const probe = ` + const path = require('node:path'),fs = require('node:fs'); + const root = fs.realpathSync(process.cwd()); + const r = require('node:module').createRequire(path.join(root,'package.json')); + for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) { + if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name); + r(name); + } + const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:'); + db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)'); + db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});}); + `; + // 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], { + cwd: source, + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' }, + timeout: 30000, + signal, + }); +} + +async function extractArchive(archive, destination) { + const entries = new Map(), + links = new Set(); + let unpacked = 0, + invalid; + await tar.t({ + file: archive, + strict: true, + onReadEntry(entry) { + try { + const name = entry.path.replace(/\/$/, ''); + if ( + !name || + name.includes('\\') || + /[\x00-\x1f\x7f]/.test(name) || + name.split('/').some((p) => !p || p === '.' || p === '..') || + !(name === 'qinglong' || name.startsWith('qinglong/')) || + entries.has(name) || + !['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type) + ) + throw new Error(`Unsafe bundle archive entry: ${name}`); + unpacked += entry.size; + if (unpacked > 2 * 1024 ** 3 || entries.size > 200000) + throw new Error('Bundle archive exceeds extraction limits'); + entries.set(name, entry.type); + if (entry.type === 'SymbolicLink') { + if ( + path.posix.isAbsolute(entry.linkpath) || + entry.linkpath.includes('\\') || + !inside( + '/qinglong', + path.posix.resolve('/', path.posix.dirname(name), entry.linkpath), + ) + ) + throw new Error('Unsafe bundle symlink'); + links.add(name); + } + } catch (error) { + invalid ||= error; + } + }, + }); + if (invalid) throw invalid; + for (const name of entries.keys()) { + let parent = path.posix.dirname(name); + while (parent !== '.') { + if (links.has(parent)) + throw new Error('Archive writes through a symlink'); + parent = path.posix.dirname(parent); + } + } + await tar.x({ + file: archive, + cwd: destination, + strict: true, + preservePaths: false, + noChown: true, + umask: 0, + }); +} + +async function stageBundle({ + root, + tmp, + env, + run, + signal, + onProgress, + verifyStatic, + download, + probe = smokeDependencies, +}) { + let local = env.QL_UPGRADE_FILE; + if (!local) { + const current = JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases. + } + const target = await runtimeTarget(); + const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH) + ? 'develop' + : 'master'; + const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); + const report = onProgress || (async () => {}); + try { + const archive = path.join(workspace, 'bundle.tar.gz'); + let expectedCommit; + if (local) { + if (!path.isAbsolute(local)) + throw new Error('Offline bundle path must be absolute'); + // Snapshot the local input so replacing it cannot race validation/extraction. + await fs.copyFile(local, archive); + } else { + const indexUrl = + env.QL_UPGRADE_INDEX_URL || + `https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`; + const checkUrl = (url) => { + const parsed = new URL(url); + if ( + parsed.protocol !== 'https:' && + !( + parsed.protocol === 'http:' && + ['127.0.0.1', '[::1]'].includes(parsed.hostname) + ) + ) + throw new Error('Upgrade URLs require HTTPS'); + }; + checkUrl(indexUrl); + const indexFile = path.join(workspace, 'index.json'); + await run('curl', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--proto', + new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https', + '--proto-redir', + '=https', + '--header', + 'Cache-Control: no-cache', + '--connect-timeout', + '10', + '--max-time', + '30', + '--max-filesize', + '1048576', + '--output', + indexFile, + indexUrl, + ]); + const index = JSON.parse(await fs.readFile(indexFile, 'utf8')); + const entry = index.packages?.[targetKey(target)]; + if ( + index.format !== 2 || + !/^[a-f0-9]{40}$/.test(index.sourceCommit) || + !entry || + !Number.isSafeInteger(entry.size) || + entry.size <= 0 || + entry.size > 512 * 1024 ** 2 || + !/^[a-f0-9]{64}$/.test(entry.sha256) + ) + throw new Error(`No verified update bundle for ${targetKey(target)}`); + checkUrl(entry.url); + expectedCommit = index.sourceCommit; + await report({ + phase: 'downloading', + artifact: 'qinglong-bundle', + downloadedBytes: 0, + totalBytes: entry.size, + percent: 0, + }); + await download( + run, + archive, + entry.url, + 'qinglong-bundle', + report, + true, + entry, + ); + } + if ((await fs.stat(archive)).size > 512 * 1024 ** 2) + throw new Error('Bundle is too large'); + await report({ + phase: 'extracting', + artifact: 'qinglong-bundle', + percent: null, + }); + await extractArchive(archive, workspace); + const incoming = path.join(workspace, 'qinglong'); + const manifest = JSON.parse( + await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'), + ); + if (expectedCommit && manifest.sourceCommit !== expectedCommit) + throw new Error('Update index and bundle commit differ'); + const selected = { + source: path.join(workspace, `qinglong-${branch}`), + static: path.join(workspace, `qinglong-static-${branch}`), + }; + await fs.rename(path.join(incoming, 'source'), selected.source); + await fs.rename(path.join(incoming, 'static'), selected.static); + await report({ phase: 'verifying', percent: null }); + await verifyPayload(selected, manifest, target); + const build = await verifyStatic(selected.source, selected.static, env); + if (build.sourceCommit !== manifest.sourceCommit) + throw new Error('Bundle and build commit differ'); + await report({ phase: 'dependencies', percent: null }); + await probe(selected.source, signal); + await fs.writeFile( + path.join(workspace, 'bundle.json'), + JSON.stringify(manifest), + { flag: 'wx', mode: 0o600 }, + ); + await fs.writeFile( + path.join(workspace, 'ready.json'), + JSON.stringify({ + ...selected, + root: path.resolve(root), + sourceCommit: manifest.sourceCommit, + dependenciesChanged: true, + bundle: true, + }), + { flag: 'wx', mode: 0o600 }, + ); + const pointer = path.join(workspace, 'pointer.json'); + await fs.writeFile( + pointer, + JSON.stringify({ directory: path.basename(workspace) }), + { flag: 'wx', mode: 0o600 }, + ); + signal?.throwIfAborted(); + const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`); + const previous = await fs + .readFile(readyPointer, 'utf8') + .catch(() => 'null'); + await fs.rename(pointer, readyPointer); + try { + const old = JSON.parse(previous)?.directory; + if ( + /^upgrade-[A-Za-z0-9_-]+$/.test(old) && + old !== path.basename(workspace) + ) + await fs.rm(path.join(tmp, old), { recursive: true, force: true }); + } catch { + /* Committed readiness must survive cleanup failure. */ + } + return selected; + } catch (error) { + await fs.rm(workspace, { recursive: true, force: true }); + throw error; + } +} + +async function verifyStagedBundle(selected) { + const manifest = JSON.parse( + await fs.readFile( + path.join(path.dirname(selected.source), 'bundle.json'), + 'utf8', + ), + ); + await verifyPayload(selected, manifest, await runtimeTarget()); + await smokeDependencies(selected.source); +} + +module.exports = { + nodeRuntimeArgs, + atLeast, + runtimeTarget, + targetKey, + assertCompatible, + inventory, + verifyPayload, + smokeDependencies, + extractArchive, + stageBundle, + verifyStagedBundle, + sha256, +}; diff --git a/cli/src/internal/maintenance/upgrade.ts b/cli/src/internal/maintenance/upgrade.ts index 1dbd2674..89204ed5 100644 --- a/cli/src/internal/maintenance/upgrade.ts +++ b/cli/src/internal/maintenance/upgrade.ts @@ -107,7 +107,7 @@ async function selectedUpgrade( export interface ReloadLifecycle { stop(context: LocalContext): Promise; - start(context: LocalContext): Promise; + start(context: LocalContext, recover?: boolean): Promise; } // All old entries stay on the same filesystem until service start succeeds. @@ -202,7 +202,7 @@ export async function replaceAndReload( if (item.existed) await fs.rename(item.backup, item.target); } try { - await lifecycle.start(context); + await lifecycle.start(context, true); } catch { throw new Error( translate( @@ -300,14 +300,31 @@ export async function reloadPanel( return await artifacts.withUpgradeLock( context.paths.dir_tmp!, () => - replaceAndReload(context, replacements, { - stop: stopPanel, - start: async (ctx) => { - const service = await startPanel(ctx); - await artifacts.waitForHealth(ctx.env, 60000, operationSignal()); - return service; - }, - }), + require('./panelUpgrade.cjs').withPreparedJob( + context.paths.dir_tmp!, + context.env, + selected, + (job?: { + onStop(): Promise; + onStart(recover?: boolean): Promise | undefined; + }) => + replaceAndReload(context, replacements, { + stop: async (ctx) => { + await job?.onStop(); + await stopPanel(ctx); + }, + start: async (ctx, recover) => { + const service = await startPanel(ctx); + await artifacts.waitForHealth( + ctx.env, + undefined, + operationSignal(), + ); + await job?.onStart(recover); + return service; + }, + }), + ), context.env, ); } finally { diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs index af8d0bb7..488db35f 100644 --- a/cli/src/internal/maintenance/upgradeArtifacts.cjs +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -218,13 +218,19 @@ async function downloadWithProgress( '--location', ...(quiet ? ['--silent', '--show-error'] : []), '--proto', - '=https', + /^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url) + ? '=http,https' + : '=https', '--proto-redir', '=https', '--connect-timeout', '30', '--max-time', - '600', + String( + artifact === 'qinglong-bundle' && expected?.size + ? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120)) + : 600, + ), '--speed-limit', '1024', '--speed-time', @@ -260,6 +266,26 @@ async function stageUpgrade({ signal, onProgress, }) { + const currentPackage = JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) { + return require('./platformBundle.cjs').stageBundle({ + root, + tmp, + env, + run, + signal, + onProgress, + verifyStatic: verifyUpgrade, + download: downloadWithProgress, + }); + } const report = onProgress || (async () => {}); const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const branch = releaseBranch(env); @@ -469,7 +495,12 @@ async function stageUpgrade({ } } -async function selectedUpgrade(tmp, branch, env = process.env) { +async function selectedUpgrade( + tmp, + branch, + env = process.env, + { metadataOnly = false } = {}, +) { const pointer = JSON.parse( await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), ); @@ -493,9 +524,13 @@ async function selectedUpgrade(tmp, branch, env = process.env) { ); if (ready.source !== selected.source || ready.static !== selected.static) throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); - const manifest = await verifyUpgrade(selected.source, selected.static, env); + const manifest = metadataOnly + ? await manifestAt(selected.static, env) + : await verifyUpgrade(selected.source, selected.static, env); if (ready.sourceCommit !== manifest.sourceCommit) throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); + if (ready.bundle && !metadataOnly) + await require('./platformBundle.cjs').verifyStagedBundle(selected); if (ready.dependenciesChanged) await fs.access(path.join(selected.source, 'node_modules')); else { @@ -640,7 +675,16 @@ async function reloadSystem({ .catch(() => {}); } -async function waitForHealth(env, timeout = 60000, signal) { +function startupHealthTimeout(runtime = process) { + // ARMv6 starts the database, scheduler and HTTP workers sequentially. Give + // slow devices time to become healthy before deciding to restore a backup. + return runtime.arch === 'arm' && + Number(runtime.config.variables.arm_version) === 6 + ? 300000 + : 60000; +} + +async function waitForHealth(env, timeout = startupHealthTimeout(), signal) { const port = Number(env.QlPort || 5700); let base = env.QlBaseUrl || ''; if (base && !base.startsWith('/')) base = `/${base}`; @@ -821,16 +865,21 @@ async function main() { tmp, () => withLifecycleHooks(root, tmp, env, async (hook) => { + let job; const lifecycle = { signal: controller.signal, - stop: () => hook('stop'), + stop: async () => { + await job?.onStop(); + await hook('stop'); + }, start: async (recover) => { await hook('start', [], recover ? undefined : controller.signal); await waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); + await job?.onStart(recover); }, }; if (process.argv[2] === 'update') { @@ -850,15 +899,38 @@ async function main() { if (process.argv[4] !== 'true') return; } else if (process.argv[2] !== 'reload') throw new Error('Unknown upgrade command.'); - await reloadSystem({ - root, - tmp, - env, - data: env.dir_data || path.join(root, 'data'), - staticRoot: env.dir_static || path.join(root, 'static'), - config: env.dir_config || path.join(root, 'data/config'), - lifecycle, - }); + const apply = async (preparedJob) => { + job = preparedJob; + await reloadSystem({ + root, + tmp, + env, + data: env.dir_data || path.join(root, 'data'), + staticRoot: env.dir_static || path.join(root, 'static'), + config: env.dir_config || path.join(root, 'data/config'), + lifecycle, + }); + }; + // Older installations can carry just this self-contained Bash verifier. + // Load panel job dependencies only when there is a panel job to reconcile. + const hasPanelJob = await fs + .access(path.join(tmp, 'panel-upgrade.json')) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + if (hasPanelJob) { + const selected = await selectedUpgrade(tmp, releaseBranch(env), env, { + metadataOnly: true, + }); + await require('./panelUpgrade.cjs').withPreparedJob( + tmp, + env, + selected, + apply, + ); + } else await apply(); console.log(upgradeMessage('更新及启动检查成功...', env)); }), env, @@ -875,6 +947,7 @@ module.exports = { selectedUpgrade, replaceAndReload, reloadSystem, + startupHealthTimeout, waitForHealth, runProcess, withLifecycleHooks, diff --git a/cli/test/internal/platformBundle.test.cjs b/cli/test/internal/platformBundle.test.cjs new file mode 100644 index 00000000..3ce4a7de --- /dev/null +++ b/cli/test/internal/platformBundle.test.cjs @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const filename = path.join(directory, 'platformBundle.cjs'); + fs.copyFileSync( + path.resolve( + __dirname, + '../../dist/internal/maintenance/platformBundle.cjs', + ), + filename, + ); + const code = ` + const Module=require('node:module'),load=Module._load; + Module._load=function(name,...args){ + if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name); + return load.call(this,name,...args); + }; + const bundle=require(process.argv[1]); + if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier'); + bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1}); + `; + const result = spawnSync(process.execPath, ['-e', code, filename], { + encoding: 'utf8', + env: { ...process.env, NODE_PATH: '' }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(`^${process.platform}-`)); +}); diff --git a/cli/test/internal/preparedUpgrade.test.cjs b/cli/test/internal/preparedUpgrade.test.cjs new file mode 100644 index 00000000..b1564868 --- /dev/null +++ b/cli/test/internal/preparedUpgrade.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { + manualUpgradeFixture, +} = require('../../../test/helpers/manual-upgrade-fixture.cjs'); +const { createContext } = require('../../dist/internal/runtime/context'); +const { reloadPanel } = require('../../dist/internal/maintenance/upgrade'); +const operator = require('../../dist/internal/maintenance/operator'); +const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs'); + +for (const wrongVersion of [false, true]) { + test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + const context = createContext({ root: f.root }, f.env); + const timeouts = []; + t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => { + timeouts.push(timeout); + }); + t.mock.method(operator, 'stopPanel', async () => { + assert.equal((await f.state()).status, 'restarting'); + }); + t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' })); + const operation = reloadPanel(context, 'system'); + if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + assert.ok(timeouts.length > 0); + assert.ok( + timeouts.every((timeout) => timeout === undefined), + 'use the shared ARMv6/default startup deadline', + ); + assert.equal( + (await f.state()).status, + wrongVersion ? 'failed' : 'completed', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'), + wrongVersion ? 'old backend' : 'new backend', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'), + 'preserve data', + ); + if (wrongVersion) + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'), + 'old dependency', + ); + }); +} diff --git a/docker/Dockerfile.armv6-glibc b/docker/Dockerfile.armv6-glibc new file mode 100644 index 00000000..f1dc4407 --- /dev/null +++ b/docker/Dockerfile.armv6-glibc @@ -0,0 +1,45 @@ +# 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 '#!/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 b/docker/Dockerfile.upgrade-bundle new file mode 100644 index 00000000..1e4b074b --- /dev/null +++ b/docker/Dockerfile.upgrade-bundle @@ -0,0 +1,30 @@ +# The image digest is supplied by the same workflow run, never a rolling tag. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} AS bundle +ARG BASE_IMAGE +ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE} +ARG TARGETARCH +ARG TARGETVARIANT +USER root +# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon. +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ + apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \ + 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 npm_config_build_from_source=true QEMU_CPU=arm1176 \ + node -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)' && \ + rm -rf build && \ + apk del .ql-bundle-build; \ + fi +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ + export QEMU_CPU=arm1176; \ + 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 || exit 1; \ + done; \ + else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi +FROM scratch +COPY --from=bundle /out/ / diff --git a/docker/Dockerfile.upgrade-bundle-armv6-glibc b/docker/Dockerfile.upgrade-bundle-armv6-glibc new file mode 100644 index 00000000..0c073ac3 --- /dev/null +++ b/docker/Dockerfile.upgrade-bundle-armv6-glibc @@ -0,0 +1,13 @@ +# 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 new file mode 100755 index 00000000..4d6a2ff7 --- /dev/null +++ b/docker/armv6-run @@ -0,0 +1,7 @@ +#!/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 new file mode 100644 index 00000000..633c48d4 --- /dev/null +++ b/docs/development/platform-upgrade-bundles.md @@ -0,0 +1,53 @@ +# 平台完整升级包 + +CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。 + +## 在线升级 + +`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。 + +面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 发布就绪指针。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。 + +渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 + +## 手动离线安装和升级 + +从同一个 `upgrade-` release 下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`。独立安装器已内置解析依赖,在目标设备无需 npm、pnpm 或重新安装 SQLite。可对照同 release 的 `latest.json` 核对归档 SHA256。 + +已有安装(默认应用并重启): + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/qinglong--linux-arm64-musl.tar.gz +``` + +只准备,稍后手动重启: + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only +ql reload system +``` + +通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。 + +安装到空目录: + +```sh +node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install +QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh +``` + +`--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。 + +## 平台兼容 + +- 最低 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 秒,超时或版本校验失败仍执行回退。依赖加载和 SQLite 读写仍由现有 Node 的正常模式独立进程验证,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。 +- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 + +自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。 diff --git a/package.json b/package.json index e36e566e..bb03612f 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,8 @@ "undici": "^7.9.0", "uuid": "^11.0.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "tar": "7.5.20" }, "devDependencies": { "@ant-design/icons": "^5.0.1", @@ -201,5 +202,6 @@ "typescript": "5.2.2", "vh-check": "^2.0.5", "virtualizedtableforantd4": "1.3.0" - } + }, + "qinglongUpgradeFormat": 2 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 863bffc4..38aac98d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ dependencies: sqlite3: specifier: npm:@whyour/sqlite3@1.1.2 version: /@whyour/sqlite3@1.1.2 + tar: + specifier: 7.5.20 + version: 7.5.20 toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -3053,7 +3056,6 @@ packages: dependencies: minipass: 7.1.3 dev: false - optional: true /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} @@ -6623,7 +6625,6 @@ packages: engines: {node: '>=18'} requiresBuild: true dev: false - optional: true /ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} @@ -10701,7 +10702,6 @@ packages: dependencies: minipass: 7.1.3 dev: false - optional: true /mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} @@ -14706,7 +14706,6 @@ packages: minizlib: 3.1.0 yallist: 5.0.0 dev: false - optional: true /terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} @@ -15727,7 +15726,6 @@ packages: engines: {node: '>=18'} requiresBuild: true dev: false - optional: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} diff --git a/scripts/build-bundle-installer.cjs b/scripts/build-bundle-installer.cjs new file mode 100644 index 00000000..34784fee --- /dev/null +++ b/scripts/build-bundle-installer.cjs @@ -0,0 +1,22 @@ +const path = require('node:path'); +require('../cli/node_modules/esbuild').buildSync({ + entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')], + outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + minify: true, + legalComments: 'inline', +}); +const { spawnSync } = require('node:child_process'); +const result = spawnSync( + process.execPath, + [path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')], + { encoding: 'utf8' }, +); +if ( + result.status !== 1 || + !result.stderr.includes('Usage: node install-bundle.cjs') +) + throw new Error('Standalone offline installer did not start correctly'); diff --git a/scripts/build-upgrade-bundle.cjs b/scripts/build-upgrade-bundle.cjs new file mode 100644 index 00000000..034d77af --- /dev/null +++ b/scripts/build-upgrade-bundle.cjs @@ -0,0 +1,141 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tar = require('tar'); +const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const { + verifyUpgrade, +} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); + +async function normalizeModes(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await fs.chmod(filename, 0o755); + await normalizeModes(filename); + } else if (entry.isFile()) { + const stat = await fs.stat(filename); + await fs.chmod(filename, 0o644 | (stat.mode & 0o111)); + } + } +} + +async function buildBundle(root, output) { + root = path.resolve(root); + output = path.resolve(output); + const target = await bundle.runtimeTarget(); + if (process.platform !== 'linux') + throw new Error('Release bundles must be built and probed on Linux'); + const build = await verifyUpgrade(root, path.join(root, 'static')); + await bundle.smokeDependencies(root); + await fs.mkdir(output, { recursive: true }); + const workspace = await fs.mkdtemp(path.join(output, '.bundle-')); + try { + const payload = path.join(workspace, 'qinglong'); + const source = path.join(payload, 'source'), + staticRoot = path.join(payload, 'static'); + await fs.mkdir(source, { recursive: true }); + const excluded = new Set([ + '.git', + '.tmp', + '.env', + 'data', + 'static', + 'output', + 'node_modules', + ]); + for (const name of await fs.readdir(root)) { + if (excluded.has(name)) continue; + await fs.cp(path.join(root, name), path.join(source, name), { + recursive: true, + verbatimSymlinks: true, + }); + } + await fs.cp( + path.join(root, 'node_modules'), + path.join(source, 'node_modules'), + { recursive: true, verbatimSymlinks: true }, + ); + await fs.cp(path.join(root, 'static'), staticRoot, { + recursive: true, + verbatimSymlinks: true, + }); + await normalizeModes(source); + await normalizeModes(staticRoot); + const manifest = { + format: 2, + sourceCommit: build.sourceCommit, + version: JSON.parse( + await fs.readFile(path.join(root, 'package.json'), 'utf8'), + ).version, + lockfileSha256: build.lockfileSha256, + target: { + platform: target.platform, + arch: target.arch, + libc: target.libc, + libcMinimum: target.libcVersion, + endianness: target.endianness, + armFloatAbi: target.armFloatAbi, + }, + nodeMinimum: '20.18.1', + nodeApi: 6, + files: await bundle.inventory({ source, static: staticRoot }), + }; + await fs.writeFile( + path.join(payload, 'bundle.json'), + JSON.stringify(manifest), + ); + console.log('Bundle inventory verified; writing archive'); + const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey( + target, + )}.tar.gz`; + await tar.c( + { + gzip: true, + portable: true, + noMtime: true, + cwd: workspace, + file: path.join(output, filename), + }, + ['qinglong'], + ); + // Verify the bytes users receive, not just the pre-archive build directory. + const roundtrip = path.join(workspace, 'roundtrip'); + await fs.mkdir(roundtrip); + console.log('Verifying archived paths, permissions and checksums'); + await bundle.extractArchive(path.join(output, filename), roundtrip); + const extracted = { + source: path.join(roundtrip, 'qinglong/source'), + static: path.join(roundtrip, 'qinglong/static'), + }; + await bundle.verifyPayload(extracted, manifest, target); + await verifyUpgrade(extracted.source, extracted.static); + await bundle.smokeDependencies(extracted.source); + console.log('Archived production dependencies and SQLite verified'); + const bytes = await fs.readFile(path.join(output, filename)); + const record = { + format: 2, + sourceCommit: build.sourceCommit, + target: bundle.targetKey(target), + filename, + baseImage: process.env.QL_BUNDLE_BASE_IMAGE, + size: bytes.length, + sha256: bundle.sha256(bytes), + }; + await fs.writeFile( + path.join(output, filename + '.json'), + JSON.stringify(record) + '\n', + ); + console.log(JSON.stringify(record)); + return record; + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} +module.exports = { buildBundle, normalizeModes }; +if (require.main === module) + buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch( + (e) => { + console.error(e); + process.exitCode = 1; + }, + ); diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs new file mode 100644 index 00000000..d323cdeb --- /dev/null +++ b/scripts/publish-upgrade-bundles.cjs @@ -0,0 +1,153 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { + sha256, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const platforms = [ + 'linux-x64-musl', + 'linux-armv6-musl', + 'linux-armv7-musl', + 'linux-arm64-musl', + 'linux-ppc64-musl', + 'linux-s390x-musl', + 'linux-ia32-musl', + 'linux-x64-glibc', + 'linux-armv6-glibc', + 'linux-armv7-glibc', + 'linux-arm64-glibc', + 'linux-ppc64-glibc', + 'linux-s390x-glibc', +]; +function releaseIndex(directory, commit, repository) { + if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository)) + throw new Error('Invalid release source'); + const packages = {}; + for (const target of platforms) { + const filename = `qinglong-${commit}-${target}.tar.gz`; + const record = JSON.parse( + fs.readFileSync(path.join(directory, filename + '.json')), + ); + const bytes = fs.readFileSync(path.join(directory, filename)); + if ( + record.format !== 2 || + record.sourceCommit !== commit || + record.target !== target || + record.filename !== filename || + record.size !== bytes.length || + record.sha256 !== sha256(bytes) + ) + throw new Error(`Unverified platform bundle: ${target}`); + packages[target] = { + size: record.size, + sha256: record.sha256, + url: `https://github.com/${repository}/releases/download/upgrade-${commit}/${filename}`, + }; + } + return { format: 2, sourceCommit: commit, packages }; +} +module.exports = { releaseIndex, platforms }; +if (require.main === module) { + const [directory, commit, repository, branch] = process.argv.slice(2); + const index = releaseIndex(directory, commit, repository); + const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs')); + index.installer = { + size: installer.length, + sha256: sha256(installer), + url: `https://github.com/${repository}/releases/download/upgrade-${commit}/install-bundle.cjs`, + }; + if (!['develop', 'master'].includes(branch)) + throw new Error('Invalid release channel'); + fs.writeFileSync( + path.join(directory, 'latest.json'), + JSON.stringify(index) + '\n', + ); + const gh = (...args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const tag = `upgrade-${commit}`; + try { + gh('release', 'view', tag, '--repo', repository); + } catch { + gh( + 'release', + 'create', + tag, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--title', + `Platform upgrade ${commit.slice(0, 12)}`, + '--notes', + 'Verified platform bundles with production dependencies. Existing Node runtime is retained.', + ); + } + const assets = fs + .readdirSync(directory) + .filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json') + .map((n) => path.join(directory, n)); + const published = JSON.parse( + gh('api', `repos/${repository}/releases/tags/${tag}`), + ); + const missing = []; + for (const file of [...assets, path.join(directory, 'latest.json')]) { + const bytes = fs.readFileSync(file); + const previous = published.assets.find( + (a) => a.name === path.basename(file), + ); + if (!previous) missing.push(file); + else if ( + previous.size !== bytes.length || + previous.digest !== `sha256:${sha256(bytes)}` + ) + throw new Error( + `Immutable release asset differs: ${path.basename(file)}`, + ); + } + if (missing.length) + gh('release', 'upload', tag, ...missing, '--repo', repository); + // An older, slower run may publish immutable assets, but must not regress a channel. + if ( + gh( + 'api', + `repos/${repository}/git/ref/heads/${branch}`, + '--jq', + '.object.sha', + ).trim() !== commit + ) { + console.log('Newer source exists; leaving channel unchanged'); + } else { + const channel = `upgrade-${branch}`; + try { + gh('release', 'view', channel, '--repo', repository); + } catch { + gh( + 'release', + 'create', + channel, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--title', + `Upgrade channel ${branch}`, + '--notes', + 'The latest.json asset selects immutable, fully verified platform bundles.', + ); + } + gh( + 'release', + 'upload', + channel, + path.join(directory, 'latest.json'), + '--repo', + repository, + '--clobber', + ); + } +} diff --git a/shell/install-bundle.cjs b/shell/install-bundle.cjs new file mode 100644 index 00000000..a95e938c --- /dev/null +++ b/shell/install-bundle.cjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node +// Also published as a standalone, bundled installer for devices with Node only. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { randomUUID } = require('node:crypto'); +const { promisify } = require('node:util'); +const execute = promisify(require('node:child_process').execFile); +const { parseArgs } = require('node:util'); +const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); +const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs'); +const { + nodeRuntimeArgs, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); + +async function runInstallerMain(argv = process.argv.slice(2)) { + const flags = nodeRuntimeArgs(); + if (!flags.length || process.execArgv.includes('--jitless')) + return installBundle(argv); + const child = require('node:child_process').spawn( + process.execPath, + [...flags, __filename, ...argv], + { stdio: 'inherit', env: process.env }, + ); + const interrupt = () => child.kill('SIGINT'); + const terminate = () => child.kill('SIGTERM'); + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + try { + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`Offline installer exited: ${signal || code}`)); + }); + }); + } finally { + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', terminate); + } +} + +async function installBundle(argv = process.argv.slice(2), env = process.env) { + const { values } = parseArgs({ + args: argv, + options: { + root: { type: 'string' }, + file: { type: 'string' }, + 'download-only': { type: 'boolean' }, + install: { type: 'boolean' }, + }, + }); + if (!values.file || !(values.root || env.QL_DIR)) + throw new Error( + 'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install]', + ); + if (values.install && values['download-only']) + throw new Error('Choose --install or --download-only'); + const root = path.resolve(values.root || env.QL_DIR); + const existing = await fs.readdir(root).catch((error) => { + if (error.code !== 'ENOENT') throw error; + return []; + }); + if (values.install && existing.length) + throw new Error('--install requires an empty installation directory'); + if (!values.install) await fs.access(path.join(root, 'shell/share.sh')); + let environment = { + ...env, + QL_DIR: root, + QL_UPGRADE_FILE: path.resolve(values.file), + }; + if (!values.install) { + const captured = await execute( + 'bash', + [ + '--noprofile', + '--norc', + '-c', + '. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0', + 'ql-offline', + root, + ], + { env: environment, maxBuffer: 1024 * 1024 }, + ); + const loaded = Object.fromEntries( + captured.stdout.split('\0').flatMap((line) => { + const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line); + return match ? [[match[1], match[2]]] : []; + }), + ); + environment = { + ...environment, + ...loaded, + QL_DIR: root, + QL_UPGRADE_FILE: path.resolve(values.file), + }; + } + const freshWorkspace = values.install + ? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-')) + : undefined; + const tmp = freshWorkspace || path.join(root, '.tmp'); + const controller = new AbortController(); + const abort = () => controller.abort(new Error('离线升级已中断')); + process.once('SIGINT', abort); + process.once('SIGTERM', abort); + try { + const operation = () => + artifacts.withUpgradeLock( + tmp, + async () => { + let state; + const report = async (patch) => { + if (patch.phase) console.log(patch.phase); + if (state) { + state = { + ...state, + ...patch, + pid: process.pid, + updatedAt: Date.now(), + }; + await jobs.saveState(tmp, state); + } + }; + if (!values.install) { + const previous = await jobs.getStatus(tmp); + if (['starting', 'running', 'restarting'].includes(previous.status)) + throw new Error('已有更新或重载正在执行。'); + state = { + id: randomUUID(), + action: values['download-only'] ? 'download' : 'apply', + status: 'running', + phase: 'verifying', + startedAt: Date.now(), + }; + await report({}); + await fs.writeFile( + path.join(tmp, 'panel-upgrade.log'), + 'Offline bundle verification\n', + { mode: 0o600 }, + ); + } + try { + const selected = await artifacts.stageUpgrade({ + root, + tmp, + env: environment, + run: (program, args, capture) => + artifacts.runProcess( + environment, + program, + args, + capture, + controller.signal, + ), + signal: controller.signal, + onProgress: report, + install: async () => { + throw new Error( + 'Offline bundles never install npm dependencies', + ); + }, + }); + if (state) + await report({ + status: 'ready', + phase: 'ready', + ...(await jobs.stagedMetadata(selected)), + }); + if (values['download-only']) { + console.log('更新已就绪;执行 ql reload system 应用更新。'); + return selected; + } + if (values.install) { + // Build beside the destination, then atomically replace only an empty root. + await fs.mkdir(path.dirname(root), { recursive: true }); + const publication = await fs.mkdtemp( + path.join(path.dirname(root), '.qinglong-install-'), + ); + try { + await fs.chmod(publication, 0o755); + await fs.cp(selected.source, publication, { + recursive: true, + verbatimSymlinks: true, + }); + await fs.cp(selected.static, path.join(publication, 'static'), { + recursive: true, + }); + await fs.copyFile( + path.join(publication, '.env.example'), + path.join(publication, '.env'), + require('node:fs').constants.COPYFILE_EXCL, + ); + await fs.chmod(path.join(publication, '.env'), 0o600); + controller.signal.throwIfAborted(); + await fs.rename(publication, root); + } finally { + await fs.rm(publication, { recursive: true, force: true }); + } + await fs.rm(path.dirname(selected.source), { + recursive: true, + force: true, + }); + await fs.rm( + path.join( + tmp, + `upgrade-ready-${artifacts.releaseBranch(environment)}.json`, + ), + { force: true }, + ); + console.log( + `离线安装完成,保留现有 Node ${ + process.version + }。启动服务:QL_DIR=${JSON.stringify( + root, + )} QL_DATA_DIR=${JSON.stringify( + path.join(root, 'data'), + )} bash ${JSON.stringify( + path.join(root, 'shell/start-simplify.sh'), + )}`, + ); + return; + } + const result = await jobs.withPanelLifecycle( + root, + tmp, + environment, + async (hook, prepareSelection) => { + const expected = await jobs.stagedMetadata(selected); + const previous = await jobs + .readRunningBuild(environment) + .catch(() => undefined); + const selection = await prepareSelection?.(selected); + try { + return await artifacts.reloadSystem({ + root, + tmp, + env: environment, + data: environment.dir_data || path.join(root, 'data'), + staticRoot: path.join(root, 'static'), + config: + environment.dir_config || path.join(root, 'data/config'), + lifecycle: { + signal: controller.signal, + stop: async () => { + await report({ + status: 'restarting', + phase: 'restarting', + }); + await hook('stop', [], controller.signal); + }, + start: async (recover) => { + await hook( + 'start', + [], + recover ? undefined : controller.signal, + ); + await artifacts.waitForHealth( + environment, + undefined, + recover ? undefined : controller.signal, + ); + if (!recover) + await jobs.verifyRunningVersion( + environment, + expected, + previous, + ); + }, + }, + additionalReplacements: selection + ? [{ source: selection.source, target: selection.target }] + : [], + }); + } finally { + if (selection) + await fs.rm(selection.directory, { + recursive: true, + force: true, + }); + } + }, + controller.signal, + ); + await report({ status: 'completed', phase: 'completed' }); + console.log('升级成功,已保留现有 Node 并运行更新后的版本。'); + return result; + } catch (error) { + if (state) await report({ status: 'failed', error: error.message }); + throw error; + } + }, + environment, + ); + return values.install + ? await operation() + : await artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + operation, + environment, + ); + } finally { + process.removeListener('SIGINT', abort); + process.removeListener('SIGTERM', abort); + if (freshWorkspace) + await fs.rm(freshWorkspace, { recursive: true, force: true }); + } +} + +module.exports = { installBundle, runInstallerMain }; +if (require.main === module) + runInstallerMain().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index de68281b..bc9bd871 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -52,6 +52,17 @@ make_dir /etc/nginx/conf.d make_dir /run/nginx 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' +require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js')) + .initGrpcCerts() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); +QL_PREPARE_GRPC_CERTS + pm2 l &>/dev/null log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')" diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 97b655b5..5740dc70 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -744,6 +744,7 @@ "更新已就绪,重启后应用新版本": "Update ready. Restart to apply the new version.", "更新在后台进行,可以继续使用面板": "The update runs in the background. You can keep using the panel.", "下载构建产物": "Downloading application files", + "下载更新包": "Downloading update package", "下载源码": "Downloading source files", "正在获取下载大小": "Total size unavailable", "查看更新日志": "View update log", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index a95b7157..4710a850 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -744,6 +744,7 @@ "更新已就绪,重启后应用新版本": "更新已就绪,重启后应用新版本", "更新在后台进行,可以继续使用面板": "更新在后台进行,可以继续使用面板", "下载构建产物": "下载构建产物", + "下载更新包": "下载更新包", "下载源码": "下载源码", "正在获取下载大小": "正在获取下载大小", "查看更新日志": "查看更新日志", diff --git a/src/pages/setting/checkUpdate.tsx b/src/pages/setting/checkUpdate.tsx index 250a34da..7c73c7c3 100644 --- a/src/pages/setting/checkUpdate.tsx +++ b/src/pages/setting/checkUpdate.tsx @@ -167,7 +167,9 @@ const CheckUpdate = ({ systemInfo }: any) => {
{intl.get( - job.artifact === 'qinglong-static' + job.artifact === 'qinglong-bundle' + ? '下载更新包' + : job.artifact === 'qinglong-static' ? '下载构建产物' : '下载源码', )} diff --git a/test/back/armv6-worker-readiness.test.cjs b/test/back/armv6-worker-readiness.test.cjs new file mode 100644 index 00000000..c9b2a7bd --- /dev/null +++ b/test/back/armv6-worker-readiness.test.cjs @@ -0,0 +1,101 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { EventEmitter } = require('node:events'); +const ts = require('typescript'); + +const source = ts.createSourceFile( + 'app.ts', + fs.readFileSync('back/app.ts', 'utf8'), + ts.ScriptTarget.Latest, + true, +); +const application = source.statements.find( + (node) => ts.isClassDeclaration(node) && node.name.text === 'Application', +); +const method = application.members.find( + (node) => node.name?.getText(source) === 'startMasterProcess', +); +const compiled = ts.transpileModule( + `class Application { ${method.getText( + source, + )} }\nmodule.exports = Application;`, + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + }, + }, +).outputText; + +for (const [arch, armVersion, timeout] of [ + ['arm', 6, 120000], + ['arm', 7, 30000], + ['arm64', undefined, 30000], +]) { + test(`${arch} v${ + armVersion ?? '' + } waits for scheduler readiness before HTTP and job recovery`, async () => { + const cluster = new EventEmitter(); + const module = { exports: {} }; + const logger = { info() {}, warn() {}, error() {} }; + new Function( + 'module', + 'cluster', + 'Logger', + 'errStack', + 'process', + compiled, + )(module, cluster, logger, String, { + arch, + config: { variables: { arm_version: armVersion } }, + exit() { + assert.fail('unexpected application exit'); + }, + }); + const app = new module.exports(); + app.workerMetadataMap = new Map(); + const forks = [], + waits = [], + notifications = []; + let ready; + app.forkWorker = (serviceType) => { + const worker = { + id: forks.length + 1, + process: { pid: 100 + forks.length }, + send: (message) => notifications.push(message), + }; + forks.push({ serviceType, worker }); + app.workerMetadataMap.set(worker.id, { serviceType }); + return worker; + }; + app.waitForWorkerReady = (worker, milliseconds) => { + waits.push({ worker, milliseconds }); + return new Promise((resolve) => { + ready = resolve; + }); + }; + app.setupMasterShutdown = () => {}; + app.startMasterProcess(); + assert.deepEqual( + forks.map((fork) => fork.serviceType), + ['grpc'], + ); + assert.equal(waits[0].milliseconds, timeout); + ready(); + await Promise.resolve(); + assert.deepEqual( + forks.map((fork) => fork.serviceType), + ['grpc', 'http'], + ); + cluster.emit('exit', forks[0].worker, 1); + assert.equal(waits[1].milliseconds, timeout); + assert.deepEqual(notifications, ['scheduler-unavailable']); + ready(); + await Promise.resolve(); + assert.deepEqual(notifications, [ + 'scheduler-unavailable', + 'reregister-crons', + ]); + }); +} diff --git a/test/back/manual-upgrade-state.test.cjs b/test/back/manual-upgrade-state.test.cjs new file mode 100644 index 00000000..64acafa3 --- /dev/null +++ b/test/back/manual-upgrade-state.test.cjs @@ -0,0 +1,70 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const execute = promisify(execFile); +const { + manualUpgradeFixture, +} = require('../helpers/manual-upgrade-fixture.cjs'); +const jobs = require('../../cli/src/internal/maintenance/panelUpgrade.cjs'); + +for (const wrongVersion of [false, true]) { + test(`Bash reload consumes offline readiness and records the actual result; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + const operation = execute( + process.execPath, + [path.resolve('shell/upgrade.cjs'), 'reload'], + { env: f.env, timeout: 15000 }, + ); + if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + const state = await f.state(); + assert.equal(state.status, wrongVersion ? 'failed' : 'completed'); + assert.equal(state.action, 'apply'); + assert.notEqual(state.id, 'offline-prepared'); + assert.equal( + await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'), + wrongVersion ? 'old backend' : 'new backend', + ); + assert.equal( + await fs.readFile(path.join(f.root, '.env'), 'utf8'), + 'preserve configuration', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'), + 'preserve data', + ); + if (wrongVersion) + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'), + 'old dependency', + ); + else + await assert.rejects( + fs.access(path.join(f.tmp, 'upgrade-ready-develop.json')), + ); + }); +} + +test('manual application holds the launch lock and does not allow a competing panel job', async (t) => { + const f = await manualUpgradeFixture(t); + await assert.rejects( + jobs.withPreparedJob(f.tmp, f.env, f.selected, async (job) => { + assert.equal((await f.state()).status, 'running'); + await assert.rejects( + jobs.startJob({ root: f.root, tmp: f.tmp, env: f.env }, 'apply'), + /已有更新/, + ); + await job.onStop(); + assert.equal((await f.state()).status, 'restarting'); + throw new Error('cancel manual operation'); + }), + /cancel manual operation/, + ); + assert.equal((await f.state()).status, 'failed'); + await assert.rejects( + fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')), + ); +}); diff --git a/test/back/panel-upgrade.test.cjs b/test/back/panel-upgrade.test.cjs index e38394ea..2d283466 100644 --- a/test/back/panel-upgrade.test.cjs +++ b/test/back/panel-upgrade.test.cjs @@ -186,7 +186,7 @@ test('detached download survives its initiating request and is idempotent; apply ); }); -test('invalid staged payload is rejected before spawning a restart', async (t) => { +test('invalid staged payload fails asynchronously before stopping the panel', async (t) => { const f = await fixture(t); await startJob(f, 'download'); assert.equal((await finished(f.tmp)).status, 'ready'); @@ -197,9 +197,16 @@ test('invalid staged payload is rejected before spawning a restart', async (t) = path.join(f.tmp, pointer.directory, 'qinglong-static-master/build/app.js'), 'corrupted', ); - await assert.rejects(startJob(f, 'apply'), /checksum|损坏/); + const queued = await startJob(f, 'apply'); + assert.equal(queued.status, 'starting'); + const failed = await finished(f.tmp); + assert.equal(failed.status, 'failed'); + assert.match(failed.error, /checksum|损坏/); assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); - assert.equal((await getStatus(f.tmp)).status, 'failed'); + assert.equal( + await fs.readFile(path.join(f.root, 'version.yaml'), 'utf8'), + 'version: 2.23.0', + ); }); test('failed download persists its failure without stopping the panel and can be retried', async (t) => { @@ -467,3 +474,15 @@ for (const knownSize of [true, false]) assert.equal(reports.at(-1).downloadedBytes, 100); assert.equal(reports.at(-1).percent, knownSize ? 100 : null); }); + +test('asynchronous apply still rejects unsafe stage pointers before dispatch', async (t) => { + const f = await fixture(t); + await startJob(f, 'download'); + assert.equal((await finished(f.tmp)).status, 'ready'); + await fs.writeFile( + path.join(f.tmp, 'upgrade-ready-master.json'), + JSON.stringify({ directory: '../outside' }), + ); + await assert.rejects(startJob(f, 'apply'), /指针|pointer/); + assert.equal(syncFs.existsSync(path.join(f.root, 'stops')), false); +}); diff --git a/test/back/platform-bundle.test.cjs b/test/back/platform-bundle.test.cjs new file mode 100644 index 00000000..ac73bd38 --- /dev/null +++ b/test/back/platform-bundle.test.cjs @@ -0,0 +1,623 @@ +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 tar = require('tar'); +const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs'); +const artifacts = require('../../shell/upgrade.cjs'); + +test('ARMv6 offline entry re-executes with an interpreted Node while preserving CLI errors', async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-armv6-entry-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const preload = path.join(directory, 'armv6.cjs'); + const observed = path.join(directory, 'child.json'); + await fs.writeFile( + preload, + `if(process.execArgv.includes('--jitless'))require('node:fs').writeFileSync(${JSON.stringify( + observed, + )},JSON.stringify({args:process.execArgv,execPath:process.execPath}));else{Object.defineProperty(process,'arch',{value:'arm'});Object.defineProperty(process,'config',{value:{variables:{arm_version:6}}});}`, + ); + const { execFile } = require('node:child_process'); + const result = await new Promise((resolve) => + execFile( + process.execPath, + [path.resolve(__dirname, '../../shell/install-bundle.cjs')], + { + timeout: 10000, + env: { + ...process.env, + NODE_OPTIONS: '--require ' + JSON.stringify(preload), + }, + }, + (error, stdout, stderr) => resolve({ error, stdout, stderr }), + ), + ); + assert.equal(result.error.code, 1); + const child = JSON.parse(await fs.readFile(observed)); + assert.ok(child.args.includes('--jitless')); + assert.equal(child.execPath, process.execPath); + assert.match(result.stderr, /Usage: node install-bundle.cjs/); +}); +const { + writeManifest, + sourceCommit, +} = require('../helpers/upgrade-fixture.cjs'); +const { + releaseIndex, + platforms, +} = require('../../scripts/publish-upgrade-bundles.cjs'); + +async function fixture(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bundle-test-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const root = path.join(directory, 'installed'), + tmp = path.join(root, '.tmp'); + const payload = path.join(directory, 'qinglong'); + const source = path.join(payload, 'source'), + staticRoot = path.join(payload, 'static'); + await fs.mkdir(tmp, { recursive: true }); + await fs.mkdir( + path.join(source, 'node_modules/.pnpm/sqlite/node_modules/sqlite3'), + { recursive: true }, + ); + await fs.symlink( + '.pnpm/sqlite/node_modules/sqlite3', + path.join(source, 'node_modules/sqlite3'), + ); + await fs.writeFile( + path.join(source, 'node_modules/sqlite3/package.json'), + '{"name":"sqlite3"}', + ); + await fs.mkdir(path.join(source, 'sample')); + await fs.writeFile(path.join(source, 'sample/config.sample.sh'), '# config'); + await fs.writeFile( + path.join(source, 'package.json'), + '{"version":"2.24.0","qinglongUpgradeFormat":2}', + ); + await fs.writeFile( + path.join(root, 'package.json'), + '{"qinglongUpgradeFormat":2}', + ); + await fs.mkdir(path.join(staticRoot, 'build'), { recursive: true }); + await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'backend'); + writeManifest(source, staticRoot); + const target = await bundle.runtimeTarget(); + const manifest = { + format: 2, + sourceCommit, + lockfileSha256: bundle.sha256( + await fs.readFile(path.join(source, 'pnpm-lock.yaml')), + ), + target: { ...target, libcMinimum: target.libcVersion }, + nodeMinimum: '20.18.1', + nodeApi: 6, + files: await bundle.inventory({ source, static: staticRoot }), + }; + const archive = path.join(directory, 'package.tar.gz'); + const pack = async () => { + await fs.writeFile( + path.join(payload, 'bundle.json'), + JSON.stringify(manifest), + ); + await tar.c( + { + file: archive, + cwd: directory, + gzip: true, + portable: true, + noMtime: true, + }, + ['qinglong'], + ); + }; + await pack(); + return { + directory, + root, + tmp, + source, + staticRoot, + target, + manifest, + archive, + pack, + }; +} + +test('online and offline stage the same complete payload, preserve pnpm links and never install dependencies', async (t) => { + const f = await fixture(t), + bytes = await fs.readFile(f.archive), + progress = []; + const entry = { + size: bytes.length, + sha256: bundle.sha256(bytes), + url: 'https://example.invalid/immutable.tar.gz', + }; + let calls = 0, + probes = 0; + const run = async (program, args) => { + assert.equal(program, 'curl'); + calls++; + const output = args[args.indexOf('--output') + 1]; + if (args.at(-1).endsWith('latest.json')) + await fs.writeFile( + output, + JSON.stringify({ + format: 2, + sourceCommit, + packages: { [bundle.targetKey(f.target)]: entry }, + }), + ); + else { + await fs.writeFile(output, bytes); + await fs.writeFile( + args[args.indexOf('--dump-header') + 1], + 'HTTP/2 200 OK\r\n\r\n', + ); + } + }; + const options = { + root: f.root, + tmp: f.tmp, + env: { QL_BRANCH: 'develop' }, + run, + verifyStatic: artifacts.verifyUpgrade, + download: artifacts.downloadWithProgress, + probe: async () => { + probes++; + }, + onProgress: async (p) => progress.push(p), + }; + const online = await bundle.stageBundle(options); + const first = await bundle.inventory(online); + assert.equal(calls, 2); + assert.equal(probes, 1); + assert.ok(progress.some((p) => p.percent === 100)); + assert.ok(progress.some((p) => p.percent === 0)); + const offline = await bundle.stageBundle({ + ...options, + env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive }, + run: () => assert.fail('offline network access'), + }); + assert.equal(probes, 2); + assert.deepEqual(await bundle.inventory(offline), first); + assert.equal( + await fs.readlink(path.join(offline.source, 'node_modules/sqlite3')), + '.pnpm/sqlite/node_modules/sqlite3', + ); + await assert.rejects(fs.access(online.source)); +}); + +test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains the existing Node requirement', async (t) => { + const f = await fixture(t), + target = { + platform: 'linux', + arch: 'armv6', + libc: 'musl', + libcVersion: '1.2.5', + }; + const manifest = { + ...f.manifest, + target: { ...target, libcMinimum: '1.2.4' }, + }; + bundle.assertCompatible(manifest, target, '20.20.2'); + const glibcTarget = { + ...target, + libc: 'glibc', + libcVersion: '2.31', + endianness: 'LE', + armFloatAbi: 'hard', + }; + const glibcManifest = { + ...manifest, + target: { ...glibcTarget, libcMinimum: '2.31' }, + }; + bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1'); + assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc'); + assert.throws( + () => + bundle.assertCompatible(glibcManifest, { + ...glibcTarget, + libcVersion: '2.30', + }), + /requires glibc/, + ); + assert.throws( + () => + bundle.assertCompatible(glibcManifest, { + ...glibcTarget, + armFloatAbi: 'soft', + }), + /floating-point ABI mismatch/, + ); + assert.throws( + () => bundle.assertCompatible(manifest, { ...target, arch: 'armv7' }), + /platform mismatch/, + ); + assert.throws( + () => bundle.assertCompatible(manifest, { ...target, libc: 'glibc' }), + /platform mismatch/, + ); + assert.throws( + () => bundle.assertCompatible(manifest, target, '18.20.0'), + /requires Node/, + ); + assert.throws( + () => + bundle.assertCompatible(manifest, { ...target, libcVersion: '1.1.24' }), + /requires musl/, + ); +}); + +test('dependency tampering and payload link escapes are rejected', async (t) => { + const f = await fixture(t), + selected = { source: f.source, static: f.staticRoot }; + await bundle.verifyPayload(selected, f.manifest, f.target); + await fs.writeFile( + path.join(f.source, 'node_modules/sqlite3/package.json'), + '{"tampered":true}', + ); + await assert.rejects( + bundle.verifyPayload(selected, f.manifest, f.target), + /checksums/, + ); + await fs.symlink('/etc/passwd', path.join(f.source, 'outside')); + await assert.rejects(bundle.inventory(selected), /escapes/); +}); + +test('archive traversal, absolute links and writes through internal symlinks are rejected before extraction', async (t) => { + const f = await fixture(t), + dest = path.join(f.directory, 'extract'); + await fs.mkdir(dest); + await fs.symlink('/etc', path.join(f.directory, 'qinglong/bad')); + await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [ + 'qinglong/bad', + ]); + await assert.rejects(bundle.extractArchive(f.archive, dest), /symlink/); + await tar.c( + { cwd: f.directory, file: f.archive, gzip: true, prefix: '../outside' }, + ['qinglong/source/package.json'], + ); + await assert.rejects(bundle.extractArchive(f.archive, dest), /Unsafe/); + await fs.mkdir(path.join(f.directory, 'qinglong/real')); + await fs.writeFile(path.join(f.directory, 'qinglong/real/file'), 'value'); + await fs.symlink('real', path.join(f.directory, 'qinglong/link')); + await tar.c({ cwd: f.directory, file: f.archive, gzip: true }, [ + 'qinglong/link', + 'qinglong/link/file', + ]); + await assert.rejects( + bundle.extractArchive(f.archive, dest), + /through a symlink/, + ); + assert.deepEqual(await fs.readdir(dest), []); +}); + +test('an incomplete or corrupt platform download cannot replace an existing ready pointer', async (t) => { + const f = await fixture(t), + pointer = path.join(f.tmp, 'upgrade-ready-develop.json'); + await fs.writeFile(pointer, 'previous-ready'); + await assert.rejects( + bundle.stageBundle({ + root: f.root, + tmp: f.tmp, + env: { QL_BRANCH: 'develop', QL_UPGRADE_FILE: f.archive }, + verifyStatic: artifacts.verifyUpgrade, + probe: async () => { + throw new Error('native addon failed'); + }, + }), + /native addon failed/, + ); + assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-ready'); + assert.equal( + (await fs.readdir(f.tmp)).filter((n) => n.startsWith('upgrade-')).length, + 1, + ); +}); + +test('publishing requires every platform and validates all immutable archive hashes', async (t) => { + const f = await fixture(t), + repository = 'whyour/qinglong'; + assert.throws(() => releaseIndex(f.directory, sourceCommit, repository)); + for (const target of platforms) { + const filename = `qinglong-${sourceCommit}-${target}.tar.gz`, + bytes = Buffer.from(target); + await fs.writeFile(path.join(f.directory, filename), bytes); + await fs.writeFile( + path.join(f.directory, filename + '.json'), + JSON.stringify({ + format: 2, + sourceCommit, + target, + filename, + size: bytes.length, + sha256: bundle.sha256(bytes), + }), + ); + } + const index = releaseIndex(f.directory, sourceCommit, repository); + assert.equal(Object.keys(index.packages).length, 13); + const armv6 = path.join( + f.directory, + `qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`, + ); + const armv6Bytes = await fs.readFile(armv6); + await fs.rm(armv6); + assert.throws( + () => releaseIndex(f.directory, sourceCommit, repository), + /ENOENT/, + ); + await fs.writeFile(armv6, armv6Bytes); + await fs.appendFile( + path.join(f.directory, `qinglong-${sourceCommit}-${platforms[0]}.tar.gz`), + 'corrupt', + ); + assert.throws( + () => releaseIndex(f.directory, sourceCommit, repository), + /Unverified/, + ); +}); + +test('fresh offline installation needs no package manager, keeps Node and publishes a complete directory', async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.source, '.env.example'), 'QlPort=5700\n'); + await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n'); + await fs.writeFile( + path.join(f.source, 'node_modules/sqlite3/index.js'), + `exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};`, + ); + for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) { + const dir = path.join(f.source, 'node_modules', name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};'); + } + f.manifest.files = await bundle.inventory({ + source: f.source, + static: f.staticRoot, + }); + await f.pack(); + const destination = path.join(f.directory, 'fresh'), + node = process.execPath; + await require('../../shell/install-bundle.cjs').installBundle( + ['--root', destination, '--file', f.archive, '--install'], + { ...process.env, PATH: '', QL_BRANCH: 'develop' }, + ); + assert.equal(process.execPath, node); + assert.equal( + await fs.readFile(path.join(destination, '.env'), 'utf8'), + 'QlPort=5700\n', + ); + assert.equal( + (await fs.stat(path.join(destination, '.env'))).mode & 0o777, + 0o600, + ); + assert.equal( + await fs.readFile(path.join(destination, 'static/build/app.js'), 'utf8'), + 'backend', + ); + assert.ok( + ( + await fs.stat(path.join(destination, 'node_modules/sqlite3/package.json')) + ).isFile(), + ); + assert.equal( + (await fs.readdir(f.directory)).filter((n) => + n.startsWith('.qinglong-install-'), + ).length, + 0, + ); + await fs.writeFile(path.join(destination, 'keep-user-data'), 'keep'); + await assert.rejects( + require('../../shell/install-bundle.cjs').installBundle([ + '--root', + destination, + '--file', + f.archive, + '--install', + ]), + /empty installation directory/, + ); + assert.equal( + await fs.readFile(path.join(destination, 'keep-user-data'), 'utf8'), + 'keep', + ); +}); + +test('offline staging replaces stale panel readiness and refuses an active online job', async (t) => { + const f = await fixture(t); + await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n'); + await fs.writeFile( + path.join(f.source, 'node_modules/sqlite3/index.js'), + 'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){cb(null,{value:42})}close(){}};', + ); + for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) { + const dir = path.join(f.source, 'node_modules', name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};'); + } + f.manifest.files = await bundle.inventory({ + source: f.source, + static: f.staticRoot, + }); + await f.pack(); + await fs.mkdir(path.join(f.root, 'shell')); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + 'import_config() { :; }\n', + ); + await fs.writeFile( + path.join(f.root, 'shell/env.sh'), + 'export QL_BRANCH=develop\n', + ); + const stateFile = path.join(f.tmp, 'panel-upgrade.json'); + await fs.writeFile( + stateFile, + JSON.stringify({ + id: 'previous-online-job', + status: 'ready', + phase: 'ready', + }), + ); + const install = require('../../shell/install-bundle.cjs').installBundle; + await install(['--root', f.root, '--file', f.archive, '--download-only']); + const state = JSON.parse(await fs.readFile(stateFile, 'utf8')); + assert.equal(state.status, 'ready'); + assert.equal(state.sourceCommit, sourceCommit); + assert.notEqual(state.id, 'previous-online-job'); + assert.equal(state.version, '2.24.0'); + const pointer = await fs.readFile( + path.join(f.tmp, 'upgrade-ready-develop.json'), + 'utf8', + ); + await fs.writeFile( + stateFile, + JSON.stringify({ + id: 'live-online-job', + status: 'running', + pid: process.pid, + startedAt: Date.now(), + }), + ); + await assert.rejects( + install(['--root', f.root, '--file', f.archive, '--download-only']), + /已有更新/, + ); + assert.equal( + JSON.parse(await fs.readFile(stateFile, 'utf8')).id, + 'live-online-job', + ); + assert.equal( + await fs.readFile(path.join(f.tmp, 'upgrade-ready-develop.json'), 'utf8'), + pointer, + ); +}); + +test('interrupting offline staging records failure, preserves prior readiness and releases both locks', async (t) => { + const f = await fixture(t); + const { spawn } = require('node:child_process'); + await fs.writeFile(path.join(f.source, 'version.yaml'), 'version: 2.24.0\n'); + await fs.writeFile( + path.join(f.source, 'node_modules/sqlite3/index.js'), + 'exports.Database=class {serialize(fn){fn()}run(){}get(sql,cb){setTimeout(()=>cb(null,{value:42}),10000)}close(){}};', + ); + for (const name of ['sequelize', 'cron-parser', '@grpc/grpc-js', 'undici']) { + const dir = path.join(f.source, 'node_modules', name); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'index.js'), 'module.exports={};'); + } + f.manifest.files = await bundle.inventory({ + source: f.source, + static: f.staticRoot, + }); + await f.pack(); + await fs.mkdir(path.join(f.root, 'shell')); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + 'import_config() { :; }\n', + ); + await fs.writeFile( + path.join(f.root, 'shell/env.sh'), + 'export QL_BRANCH=develop\n', + ); + const pointer = path.join(f.tmp, 'upgrade-ready-develop.json'); + await fs.writeFile(pointer, 'previous-good-readiness'); + const child = spawn( + process.execPath, + [ + path.resolve(__dirname, '../../shell/install-bundle.cjs'), + '--root', + f.root, + '--file', + f.archive, + '--download-only', + ], + { stdio: 'ignore' }, + ); + t.after(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }); + const exited = new Promise((resolve, reject) => { + child.once('exit', resolve); + child.once('error', reject); + }); + let seen = false; + for (let i = 0; i < 200; i++) { + const state = await fs + .readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8') + .then(JSON.parse) + .catch(() => ({})); + if (state.phase === 'dependencies') { + seen = true; + break; + } + await new Promise((r) => setTimeout(r, 20)); + } + assert.ok(seen); + child.kill('SIGTERM'); + assert.equal(await exited, 1); + assert.equal(await fs.readFile(pointer, 'utf8'), 'previous-good-readiness'); + assert.equal( + JSON.parse( + await fs.readFile(path.join(f.tmp, 'panel-upgrade.json'), 'utf8'), + ).status, + 'failed', + ); + await assert.rejects(fs.access(path.join(f.tmp, 'upgrade.lock'))); + await assert.rejects( + fs.access(path.join(f.tmp, 'panel-job-launch/upgrade.lock')), + ); +}); + +test('portable release archives preserve the permission manifest after normalizing image scripts', async (t) => { + const f = await fixture(t); + const script = path.join(f.source, 'sample/config.sample.sh'); + await fs.chmod(script, 0o777); + const { normalizeModes } = require('../../scripts/build-upgrade-bundle.cjs'); + await normalizeModes(f.source); + await normalizeModes(f.staticRoot); + assert.equal((await fs.stat(script)).mode & 0o777, 0o755); + f.manifest.files = await bundle.inventory({ + source: f.source, + static: f.staticRoot, + }); + await f.pack(); + const extracted = path.join(f.directory, 'roundtrip'); + await fs.mkdir(extracted); + await bundle.extractArchive(f.archive, extracted); + const selected = { + source: path.join(extracted, 'qinglong/source'), + static: path.join(extracted, 'qinglong/static'), + }; + await bundle.verifyPayload(selected, f.manifest, f.target); + await fs.chmod(path.join(selected.source, 'sample/config.sample.sh'), 0o777); + await assert.rejects( + bundle.verifyPayload(selected, f.manifest, f.target), + /checksums/, + ); +}); + +test('full dependency bundles get a bounded slow-link download budget instead of the old ten-minute limit', async (t) => { + const f = await fixture(t); + let observed; + await assert.rejects( + artifacts.downloadWithProgress( + async (program, args) => { + assert.equal(program, 'curl'); + observed = Number(args[args.indexOf('--max-time') + 1]); + assert.ok(observed > 600 && observed <= 7200); + assert.equal(args[args.indexOf('--speed-limit') + 1], '1024'); + throw new Error('stop before transfer'); + }, + path.join(f.directory, 'slow.tar.gz'), + 'https://example.invalid/package.tar.gz', + 'qinglong-bundle', + async () => {}, + true, + { size: 24 * 1024 * 1024, sha256: 'a'.repeat(64) }, + ), + /stop before transfer/, + ); + assert.ok(observed); +}); diff --git a/test/back/start-certificate-preparation.test.cjs b/test/back/start-certificate-preparation.test.cjs new file mode 100644 index 00000000..827600e2 --- /dev/null +++ b/test/back/start-certificate-preparation.test.cjs @@ -0,0 +1,53 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8'); +const preparation = startup.slice( + startup.indexOf('\nfix_config\n'), + startup.indexOf('log_with_style "INFO" "$(t \'🔄'), +); +for (const fails of [false, true]) { + test(`manual startup ${ + fails + ? 'stops on certificate failure' + : 'awaits certificates before starting supervision' + }`, (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-start-certs-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const module = path.join(root, 'static/build/config/grpcCerts.js'); + fs.mkdirSync(path.dirname(module), { recursive: true }); + fs.writeFileSync( + module, + `exports.initGrpcCerts = () => new Promise((resolve, reject) => setTimeout(() => { + if (process.env.QA_CERT_FAILURE === '1') return reject(new Error('certificate preparation failed')); + require('node:fs').writeFileSync(require('node:path').join(process.env.QL_DIR, 'certs-ready'), 'ready'); + resolve(); + }, 50));`, + ); + const result = spawnSync( + '/bin/bash', + [ + '-c', + `set -e +fix_config(){ :; } +pm2(){ test -f "$QL_DIR/certs-ready"; touch "$QL_DIR/supervisor-started"; } +${preparation}`, + ], + { + encoding: 'utf8', + env: { + ...process.env, + QL_DIR: root, + QA_CERT_FAILURE: fails ? '1' : '0', + }, + }, + ); + assert.equal(result.status, fails ? 1 : 0, result.stderr); + assert.equal(fs.existsSync(path.join(root, 'supervisor-started')), !fails); + if (fails) assert.match(result.stderr, /certificate preparation failed/); + }); +} diff --git a/test/back/upgrade-guards.test.cjs b/test/back/upgrade-guards.test.cjs index 741e5bc9..25579cfd 100644 --- a/test/back/upgrade-guards.test.cjs +++ b/test/back/upgrade-guards.test.cjs @@ -283,3 +283,54 @@ test('unhealthy, malformed and oversized health responses fail within the deadli await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/); } }); + +test('ARMv6 startup can become healthy after the standard one-minute deadline', async (t) => { + const { EventEmitter } = require('node:events'); + const descriptors = ['arch', 'config'].map((key) => [ + key, + Object.getOwnPropertyDescriptor(process, key), + ]); + t.after(() => { + for (const [key, descriptor] of descriptors) + Object.defineProperty(process, key, descriptor); + }); + Object.defineProperty(process, 'arch', { value: 'arm', configurable: true }); + Object.defineProperty(process, 'config', { + value: { variables: { arm_version: 6 } }, + configurable: true, + }); + let now = 0; + let probes = 0; + t.mock.method(Date, 'now', () => now); + t.mock.method(http, 'get', (_options, callback) => { + const request = new EventEmitter(); + request.destroy = (error) => request.emit('error', error); + queueMicrotask(() => { + const response = new EventEmitter(); + response.statusCode = ++probes === 1 ? 503 : 200; + callback(response); + now = probes * 70000; + response.emit( + 'data', + JSON.stringify({ + code: response.statusCode, + data: { status: probes > 1 ? 'ok' : 'starting' }, + }), + ); + response.emit('end'); + }); + return request; + }); + await waitForHealth({}); + assert.equal(probes, 2); + assert.equal(now, 140000); + const { startupHealthTimeout } = require('../../shell/upgrade.cjs'); + assert.equal( + startupHealthTimeout({ + arch: 'arm', + config: { variables: { arm_version: 7 } }, + }), + 60000, + ); + assert.equal(startupHealthTimeout({ arch: 'arm64' }), 60000); +}); diff --git a/test/helpers/manual-upgrade-fixture.cjs b/test/helpers/manual-upgrade-fixture.cjs new file mode 100644 index 00000000..40df4631 --- /dev/null +++ b/test/helpers/manual-upgrade-fixture.cjs @@ -0,0 +1,134 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { createHash } = require('node:crypto'); +const { writeManifest, sourceCommit } = require('./upgrade-fixture.cjs'); + +async function manualUpgradeFixture(t, wrongVersion = false) { + const root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-job-')), + ); + const tmp = path.join(root, '.tmp'); + const workspace = path.join(tmp, 'upgrade-manual'); + const selected = { + source: path.join(workspace, 'qinglong-develop'), + static: path.join(workspace, 'qinglong-static-develop'), + }; + for (const dir of [ + path.join(root, 'static/build'), + path.join(root, 'shell'), + path.join(root, 'data/config'), + path.join(root, 'node_modules'), + path.join(selected.source, 'sample'), + path.join(selected.source, 'node_modules'), + path.join(selected.static, 'build'), + ]) + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(root, 'static/build/app.js'), 'old backend'); + await fs.writeFile(path.join(root, '.env'), 'preserve configuration'); + await fs.writeFile(path.join(root, 'data/keep'), 'preserve data'); + await fs.writeFile(path.join(root, 'node_modules/old'), 'old dependency'); + await fs.writeFile( + path.join(root, 'shell/share.sh'), + 'import_config(){ :; }\ndelete_pm2(){ :; }\nreload_pm2(){ :; }\n', + ); + await fs.writeFile(path.join(root, 'shell/env.sh'), ''); + await fs.writeFile( + path.join(selected.source, 'sample/config.sample.sh'), + '# sample', + ); + await fs.writeFile( + path.join(selected.source, 'package.json'), + '{"version":"2.24.0"}', + ); + await fs.writeFile( + path.join(selected.source, 'version.yaml'), + 'version: 2.24.0\n', + ); + await fs.writeFile(path.join(selected.static, 'build/app.js'), 'new backend'); + writeManifest(selected.source, selected.static); + const manifest = JSON.parse( + await fs.readFile(path.join(selected.static, 'build-info.json')), + ); + const expected = { + version: '2.24.0', + sourceCommit, + backendSha256: manifest.files['build/app.js'], + stageDirectory: 'upgrade-manual', + }; + await fs.writeFile( + path.join(workspace, 'ready.json'), + JSON.stringify({ + ...selected, + root, + sourceCommit, + dependenciesChanged: true, + }), + ); + await fs.writeFile( + path.join(tmp, 'upgrade-ready-develop.json'), + '{"directory":"upgrade-manual"}', + ); + await fs.writeFile( + path.join(tmp, 'panel-upgrade.json'), + JSON.stringify({ + id: 'offline-prepared', + action: 'download', + status: 'ready', + phase: 'ready', + ...expected, + }), + ); + const server = http.createServer(async (_request, response) => { + const content = await fs + .readFile(path.join(root, 'static/build/app.js'), 'utf8') + .catch(() => 'stopped'); + const updated = content === 'new backend'; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + code: 200, + data: { + status: 'ok', + services: { http: true, grpc: true }, + build: { + version: updated ? '2.24.0' : '2.23.0', + sourceCommit: + updated && !wrongVersion ? sourceCommit : 'b'.repeat(40), + backendSha256: createHash('sha256').update(content).digest('hex'), + instanceId: updated ? 'new-instance' : 'old-instance', + parentPid: updated ? 222 : 111, + }, + }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }); + const env = { + ...process.env, + QL_DIR: root, + QL_BRANCH: 'develop', + dir_tmp: tmp, + dir_static: path.join(root, 'static'), + dir_data: path.join(root, 'data'), + dir_config: path.join(root, 'data/config'), + QlPort: String(server.address().port), + QlBaseUrl: '/', + }; + return { + root, + tmp, + selected, + env, + expected, + state: async () => + JSON.parse(await fs.readFile(path.join(tmp, 'panel-upgrade.json'))), + }; +} +module.exports = { manualUpgradeFixture };