diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index eb05f9c0..f8c4b197 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -8,6 +8,9 @@ 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/upgradePrestart.cjs" + - "cli/src/internal/maintenance/panelUpgrade.cjs" - "!.github/workflows/cli-package.yml" branches: - "master" @@ -16,6 +19,10 @@ on: - "v*" workflow_dispatch: inputs: + build_development_bundles: + description: Develop only - build and publish shared develop/debian-dev upgrade packages + type: boolean + default: false static_sync_only: description: Synchronize an existing static snapshot to GitLab and verify Gitee type: boolean @@ -33,6 +40,14 @@ on: permissions: contents: read +# Keep master image tags and their bundle indexes in one serialized release. +# A rerun may intentionally replace a version, including from the same commit. +# Manual development bundle releases are serialized independently of master. +# Ordinary develop pushes and Git-tag builds remain independent. +concurrency: + group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }} + cancel-in-progress: false + jobs: validate: if: ${{ !inputs.static_sync_only }} @@ -203,6 +218,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 +285,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 +307,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 +373,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 +584,97 @@ jobs: echo '::error::Could not check the published npm version; refusing to publish.' exit 1 fi + + build-upgrade-bundles: + # Development packages are opt-in; both channels use this run's image digests. + if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }} + 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: + if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_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 }} + QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }} + run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" diff --git a/back/app.ts b/back/app.ts index 8a195c00..38821cc2 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 @@ -148,32 +155,8 @@ class Application { } private async setupMiddlewares(): Promise { - // Only the HTTP worker needs an Express application and its middleware. - const [ - { default: createExpress }, - { default: helmet }, - { default: cors }, - { default: compression }, - { monitoringMiddleware }, - ] = await Promise.all([ - import('express'), - import('helmet'), - import('cors'), - import('compression'), - import('./middlewares/monitoring'), - ]); - const app = createExpress(); - app.use((req, res, next) => { - if (req.query.t) delete req.query.t; - next(); - }); - app.use(helmet({ - contentSecurityPolicy: false, - })); - app.use(cors(config.cors)); - app.use(compression()); - app.use(monitoringMiddleware); - return app; + const { createHttpApplication } = await import('./loaders/httpApplication'); + return createHttpApplication(); } private setupMasterShutdown() { diff --git a/back/bootstrap/upgradeProbe.ts b/back/bootstrap/upgradeProbe.ts new file mode 100644 index 00000000..87f93782 --- /dev/null +++ b/back/bootstrap/upgradeProbe.ts @@ -0,0 +1,112 @@ +// A single, disposable backend process. Resolve startup dependencies, but never +// run appLoader: its initData/initTask hooks install dependencies and start user work. +import 'reflect-metadata'; +import dns from 'dns'; +import http from 'http'; + +async function probeUpgrade() { + if (!process.send || process.env.QL_UPGRADE_PROBE !== '1') + throw new Error('Upgrade probe requires its isolated supervisor'); + dns.setDefaultResultOrder('ipv4first'); + const { default: config } = await import('../config'); + const { sequelize } = await import('../data'); + const { default: loadDatabase } = await import('../loaders/db'); + const { Container } = await import('typedi'); + const { GrpcServerService } = await import('../services/grpc'); + const { HttpServerService } = await import('../services/http'); + const { createHttpApplication } = await import('../loaders/httpApplication'); + const { default: loadServer } = await import('../loaders/server'); + const { default: loadExpress } = await import('../loaders/express'); + await import('../loaders/app'); // Resolve normal startup hooks without running them. + const { default: inject } = await import('../loaders/depInjector'); + const grpc = Container.get(GrpcServerService); + const server = Container.get(HttpServerService); + try { + await loadDatabase(); + // Exercise an actual on-disk transaction as well as schema initialization. + await sequelize.transaction(async (transaction) => { + await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', { + transaction, + }); + await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', { + transaction, + }); + const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', { + transaction, + }); + if ((rows[0] as { value: number })?.value !== 42) + throw new Error('Upgrade database readback failed'); + }); + inject(); + config.grpcPort = await grpc.initialize(); + const { default: cronClient } = await import('../schedule/client'); + cronClient.readiness.configure(async () => {}); + if (!(await cronClient.readiness.recover())) + throw new Error('Upgrade scheduler readiness failed'); + const app = await createHttpApplication(); + loadExpress({ app }); + const listener = await server.initialize(app, 0); + await loadServer({ server: listener }); + const address = listener.address(); + if (!address || typeof address === 'string') + throw new Error('No probe HTTP port'); + const health = await new Promise((resolve, reject) => { + const request = http.get( + `http://127.0.0.1:${address.port}/api/health`, + (response) => { + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('error', reject); + response.on('end', () => { + try { + const result = JSON.parse(body); + if ( + response.statusCode !== 200 || + result.code !== 200 || + result.data?.status !== 'ok' || + !result.data.services?.grpc || + !result.data.services?.http + ) + throw new Error('Upgrade HTTP/gRPC health check failed'); + resolve(result.data); + } catch (error) { + reject(error); + } + }); + }, + ); + request.setTimeout(10000, () => + request.destroy(new Error('Upgrade health request timed out')), + ); + request.on('error', reject); + }); + process.send!({ + type: 'upgrade-probe', + ok: true, + build: health.build, + memory: process.memoryUsage(), + pid: process.pid, + }); + } finally { + await server.shutdown(); + grpc.getServer().forceShutdown(); + await sequelize.close(); + } +} + +process.once('disconnect', () => process.exit(1)); +probeUpgrade() + .then(() => process.exit(0)) + .catch((error) => { + process.send?.({ + type: 'upgrade-probe', + ok: false, + code: error.code, + message: error.message, + stack: error.stack, + }); + console.error(error); + process.exit(1); + }); diff --git a/back/loaders/httpApplication.ts b/back/loaders/httpApplication.ts new file mode 100644 index 00000000..4d1cd98b --- /dev/null +++ b/back/loaders/httpApplication.ts @@ -0,0 +1,33 @@ +import type express from 'express'; +import config from '../config'; + +export async function createHttpApplication(): Promise { + // Only the HTTP worker needs an Express application and its middleware. + const [ + { default: createExpress }, + { default: helmet }, + { default: cors }, + { default: compression }, + { monitoringMiddleware }, + ] = await Promise.all([ + import('express'), + import('helmet'), + import('cors'), + import('compression'), + import('../middlewares/monitoring'), + ]); + const app = createExpress(); + app.use((req, res, next) => { + if (req.query.t) delete req.query.t; + next(); + }); + app.use( + helmet({ + contentSecurityPolicy: false, + }), + ); + app.use(cors(config.cors)); + app.use(compression()); + app.use(monitoringMiddleware); + return app; +} diff --git a/back/services/grpc.ts b/back/services/grpc.ts index c1c46c2b..4c667a8d 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -50,13 +50,13 @@ export class GrpcServerService { for (const host of hostsToTry) { try { const address = this.formatGrpcAddress(host, grpcPort); - await bindAsync(address, credentials); + const boundPort = await bindAsync(address, credentials); Logger.debug(`[boot] gRPC service started successfully on ${address}`); metricsService.record('grpc_service_start', 1, { port: grpcPort.toString(), host }); - return grpcPort; + return boundPort; } catch (err) { lastError = err as Error; Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err); 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/operator.ts b/cli/src/internal/maintenance/operator.ts index 55307bc4..be33e645 100644 --- a/cli/src/internal/maintenance/operator.ts +++ b/cli/src/internal/maintenance/operator.ts @@ -150,6 +150,14 @@ export async function startPanel( GRPC_PORT: context.env.QlGrpcPort || '5500', }, }; + // Archive selection and launcher ownership apply only to the upgrade worker. + // Retaining them in PM2 would pin the next online update to an offline file. + for (const name of [ + 'QL_UPGRADE_FILE', + 'QL_UPGRADE_REPAIR_DEPS', + 'QL_UPGRADE_LAUNCHER_PID', + ]) + delete options.env[name as keyof typeof options.env]; if (await executableAvailable('pm2', context.env)) { const flushed = await runProcess('pm2', ['flush'], options); if (flushed.code === 0) { diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 72749b72..3b119093 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,93 @@ async function getStatus(tmp) { return state; } +// Manual restarts accept the currently prepared package, including one retained +// after a failed preparation. Legacy installs without jobs keep their old path; +// complete-bundle installs always verify and record the actual running build. +async function withPreparedJob(tmp, env, selected, operation) { + const previous = await getStatus(tmp); + if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。'); + const hasRecord = await fs + .access(stateFile(tmp)) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + if (!hasRecord) { + const pkg = env.QL_DIR + ? JSON.parse( + await fs + .readFile(path.join(env.QL_DIR, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ) + : {}; + if (pkg.qinglongUpgradeFormat !== 2) 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('已有更新或重载正在执行。'); + const prepared = await artifacts.selectedUpgrade( + tmp, + artifacts.releaseBranch(env), + env, + { metadataOnly: true }, + ); + if ( + prepared.source !== selected.source || + prepared.static !== selected.static + ) + throw new Error('暂存更新已变化,请重新下载更新。'); + const metadata = await stagedMetadata(selected); + const previousBuild = await readRunningBuild(env).catch(() => undefined); + const { log, error, ...record } = current; + let state = { + ...record, + ...metadata, + 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 +308,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 +480,7 @@ async function runJob({ root, tmp, env = process.env }, id) { ); await artifacts.waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); if (!recover) @@ -429,16 +519,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..4315ca34 --- /dev/null +++ b/cli/src/internal/maintenance/platformBundle.cjs @@ -0,0 +1,579 @@ +// 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 { + prestartBackend, + dependencyFailure, + repairDependencies, +} = require('./upgradePrestart.cjs'); +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, + prestart = prestartBackend, + repair = repairDependencies, +}) { + 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 }); + let dependencyFiles; + const testCandidate = async () => { + await probe(selected.source, signal); + await report({ phase: 'prestarting', percent: null }); + return prestart(selected, { target, signal }); + }; + try { + await testCandidate(); + } catch (error) { + signal?.throwIfAborted(); + if (!dependencyFailure(error)) throw error; + if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1') + throw new Error( + `Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`, + ); + await report({ phase: 'repairing', percent: null }); + await repair(selected.source, { target, signal, env }); + const repaired = await inventory({ + source: selected.source, + static: selected.static, + }); + dependencyFiles = Object.fromEntries( + Object.entries(repaired).filter(([name]) => + name.startsWith('source/node_modules/'), + ), + ); + // A repair may change only dependencies, never bless changed application code. + await verifyPayload( + selected, + manifestWithDependencies(manifest, dependencyFiles), + target, + ); + await verifyStatic(selected.source, selected.static, env); + await testCandidate(); // Exactly one repair; this failure propagates. + } + await fs.writeFile( + path.join(workspace, 'prestart.json'), + JSON.stringify({ + format: 1, + sourceCommit: manifest.sourceCommit, + manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))), + node: process.version, + execPath: process.execPath, + completedAt: Date.now(), + ...(dependencyFiles ? { dependencyFiles } : {}), + }), + { flag: 'wx', mode: 0o600 }, + ); + 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; + } +} + +function manifestWithDependencies(manifest, dependencyFiles) { + if (!dependencyFiles) return manifest; + if ( + typeof dependencyFiles !== 'object' || + Array.isArray(dependencyFiles) || + !Object.keys(dependencyFiles).length || + Object.keys(dependencyFiles).some( + (name) => !name.startsWith('source/node_modules/'), + ) + ) + throw new Error('Invalid repaired dependency inventory'); + return { + ...manifest, + files: { + ...Object.fromEntries( + Object.entries(manifest.files).filter( + ([name]) => !name.startsWith('source/node_modules/'), + ), + ), + ...dependencyFiles, + }, + }; +} + +async function verifyStagedBundle(selected) { + const manifest = JSON.parse( + await fs.readFile( + path.join(path.dirname(selected.source), 'bundle.json'), + 'utf8', + ), + ); + const prestart = JSON.parse( + await fs.readFile( + path.join(path.dirname(selected.source), 'prestart.json'), + 'utf8', + ), + ); + if ( + prestart.format !== 1 || + prestart.sourceCommit !== manifest.sourceCommit || + prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) || + prestart.node !== process.version || + prestart.execPath !== process.execPath || + !Number.isSafeInteger(prestart.completedAt) + ) + throw new Error( + 'Upgrade prestart receipt is missing or stale; prepare the update again', + ); + await verifyPayload( + selected, + manifestWithDependencies(manifest, prestart.dependencyFiles), + 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..07a1e0b0 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 { @@ -571,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) { } catch (error) { // Do not modify files under a still-running failed startup. Keep backups // for recovery if stopping the new process itself fails. - if (attemptedStart) await lifecycle.stop(); + if (attemptedStart) await lifecycle.stop(true); for (const item of changed.reverse()) { await fs.rm(item.target, { recursive: true, force: true }); if (item.existed) { @@ -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}`; @@ -786,9 +830,18 @@ esac mode: 0o600, }, ); + // Offline selection and worker ownership belong to this operation only. + // Passing them to PM2 would pin future online updates to the old local file. + const lifecycleEnv = { ...env }; + for (const name of [ + 'QL_UPGRADE_FILE', + 'QL_UPGRADE_REPAIR_DEPS', + 'QL_UPGRADE_LAUNCHER_PID', + ]) + delete lifecycleEnv[name]; const hook = (operation, args = [], signal) => runProcess( - env, + lifecycleEnv, 'bash', ['--', snapshot, operation, ...args], false, @@ -821,16 +874,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 +908,48 @@ 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, + }); + }; + // Legacy reloads can carry only this self-contained Bash verifier. + // Complete-bundle installs also track manual applies without a job file. + const hasPanelJob = await fs + .access(path.join(tmp, 'panel-upgrade.json')) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + const packageInfo = hasPanelJob + ? {} + : JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) { + 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 +966,7 @@ module.exports = { selectedUpgrade, replaceAndReload, reloadSystem, + startupHealthTimeout, waitForHealth, runProcess, withLifecycleHooks, diff --git a/cli/src/internal/maintenance/upgradePrestart.cjs b/cli/src/internal/maintenance/upgradePrestart.cjs new file mode 100644 index 00000000..6a26efbe --- /dev/null +++ b/cli/src/internal/maintenance/upgradePrestart.cjs @@ -0,0 +1,393 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const crypto = require('node:crypto'); +const os = require('node:os'); + +function probeLimits(target) { + const small = target.arch === 'armv6'; + return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 }; +} + +function dependencyFailure(error) { + const text = `${error.message || ''}\n${error.stack || ''}\n${ + error.stderr || '' + }`; + if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text)) + return false; + if ( + /ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) && + /node_modules[/\\]/.test(text) + ) + return true; + const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec( + text, + )?.[1]; + const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1]; + const inDependencies = (filename) => + /(?:^|[/\\])node_modules[/\\]/.test(filename || ''); + // Node reports a missing package entry as an absolute path and missing + // package-internal imports as relative paths. Neither is an app source error. + const dependencyMissing = + missing && + ((!missing.startsWith('.') && + !path.isAbsolute(missing) && + !missing.startsWith('node:')) || + inDependencies(missing) || + inDependencies(importer)); + // Missing backend files, bad config and database errors must never trigger npm. + return ( + (/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) && + Boolean(dependencyMissing)) || + (/Could not locate the bindings file/.test(text) && + /node_modules[/\\]/.test(text)) + ); +} + +async function prestartBackend( + selected, + { target, signal, limits = probeLimits(target) }, +) { + signal?.throwIfAborted(); + const workspace = await fs.mkdtemp( + path.join(path.dirname(selected.source), 'prestart-'), + ); + try { + const data = path.join(workspace, 'data'); + for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload']) + await fs.mkdir(path.join(data, name), { recursive: true }); + // Copy only backend code/metadata, never all frontend assets or node_modules. + await fs.mkdir(path.join(workspace, 'static')); + await fs.cp( + path.join(selected.static, 'build'), + path.join(workspace, 'static/build'), + { recursive: true }, + ); + await fs.copyFile( + path.join(selected.static, 'build-info.json'), + path.join(workspace, 'static/build-info.json'), + ); + await fs.copyFile( + path.join(selected.source, 'version.yaml'), + path.join(workspace, 'version.yaml'), + ); + await fs.symlink( + path.join(selected.source, 'node_modules'), + path.join(workspace, 'node_modules'), + 'dir', + ); + await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 }); + const entry = path.join( + workspace, + 'static/build/bootstrap/upgradeProbe.js', + ); + await fs.access(entry); + const expected = JSON.parse( + await fs.readFile(path.join(selected.static, 'build-info.json')), + ); + const backendSha256 = crypto + .createHash('sha256') + .update(await fs.readFile(path.join(selected.static, 'build/app.js'))) + .digest('hex'); + const version = ( + await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8') + ).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + // An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and + // package-manager settings from escaping into the disposable backend. + const env = { + PATH: process.env.PATH, + LANG: process.env.LANG || 'C.UTF-8', + HOME: workspace, + TMPDIR: workspace, + NODE_ENV: 'production', + QL_DIR: workspace, + QL_DATA_DIR: data, + QL_UPGRADE_PROBE: '1', + BACK_PORT: '0', + GRPC_PORT: '0', + BIND_HOST: '127.0.0.1', + BIND_HOST_GRPC: '127.0.0.1', + QlBaseUrl: '/', + NODE_PATH: '', + NODE_OPTIONS: '', + UV_THREADPOOL_SIZE: '1', + }; + return await new Promise((resolve, reject) => { + let report, + failure, + output = '', + finished = false, + killTimer; + // No PM2/cluster workers. OpenSSL key generation is sequential in this + // one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support. + const child = spawn( + process.execPath, + [`--max-old-space-size=${limits.heapMb}`, entry], + { + cwd: workspace, + env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }, + ); + try { + if (child.pid) os.setPriority(child.pid, 10); + } catch {} + const kill = (signalName) => { + try { + if (process.platform !== 'win32' && child.pid) + process.kill(-child.pid, signalName); + else child.kill(signalName); + } catch (error) { + if (error.code !== 'ESRCH') failure ||= error; + } + }; + const stop = (error) => { + failure ||= error; + kill('SIGTERM'); + killTimer ||= setTimeout(() => kill('SIGKILL'), 1500); + }; + const abort = () => + stop(signal.reason || new Error('Upgrade prestart aborted')); + const timer = setTimeout( + () => stop(new Error('Upgrade prestart timed out')), + limits.timeoutMs, + ); + child.stdout.on('data', (chunk) => { + output = (output + chunk).slice(-32768); + }); + child.stderr.on('data', (chunk) => { + output = (output + chunk).slice(-32768); + }); + child.on('message', (message) => { + if (message?.type === 'upgrade-probe') report = message; + }); + const finish = (error) => { + if (finished) return; + finished = true; + clearTimeout(timer); + clearTimeout(killTimer); + signal?.removeEventListener('abort', abort); + if (error) reject(error); + else resolve(report); + }; + child.on('error', (error) => finish(error)); + // Descendants can retain our output pipes after their parent exits. + // Reap the group on exit rather than waiting for those pipes to close. + child.once('exit', () => kill('SIGKILL')); + child.on('close', (code, exitSignal) => { + // Reap descendants even when the backend exits unexpectedly. + kill('SIGKILL'); + if (failure) return finish(failure); + if (code !== 0 || !report?.ok) + return finish( + Object.assign( + new Error( + report?.message || + `Upgrade prestart exited: ${exitSignal || code}\n${output}`, + ), + { + code: report?.code, + stack: report?.stack || output, + signal: exitSignal, + }, + ), + ); + if ( + report.build?.sourceCommit !== expected.sourceCommit || + report.build?.backendSha256 !== backendSha256 || + report.build?.version !== version + ) + return finish(new Error('Upgrade prestart build identity mismatch')); + finish(); + }); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + }); + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} + +async function repairDependencies(source, { target, signal, env }) { + const workspace = await fs.mkdtemp( + path.join(path.dirname(source), 'repair-'), + ); + try { + const pkg = JSON.parse( + await fs.readFile(path.join(source, 'package.json')), + ); + if (pkg.packageManager !== 'pnpm@8.3.1') + throw new Error('Unsupported frozen-lockfile package manager'); + // Install apart from the verified application: lifecycle scripts cannot + // rewrite source/static files or use the old installation's node_modules. + await fs.writeFile( + path.join(workspace, 'package.json'), + JSON.stringify({ ...pkg, scripts: {} }), + ); + await fs.copyFile( + path.join(source, 'pnpm-lock.yaml'), + path.join(workspace, 'pnpm-lock.yaml'), + ); + const small = target.arch === 'armv6'; + const childEnv = { + PATH: env.PATH || process.env.PATH, + HOME: workspace, + TMPDIR: workspace, + NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${ + small ? 256 : 384 + }`, + NODE_PATH: '', + CI: 'true', + npm_config_jobs: '1', + MAKEFLAGS: '-j1', + ...(small + ? { + npm_config_build_from_source: 'true', + npm_config_force_process_config: 'true', + CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard', + CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard', + } + : {}), + }; + // Preserve network routing without inheriting Node/preload/compiler settings. + for (const name of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'all_proxy', + 'no_proxy', + ]) + if (typeof env[name] === 'string') childEnv[name] = env[name]; + // Reuse matching installed headers on small devices; never inherit a + // caller's nodedir or accidentally compile against a different Node ABI. + const prefix = path.resolve(path.dirname(process.execPath), '..'); + const header = await fs + .readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8') + .catch(() => ''); + const headerVersion = ['MAJOR', 'MINOR', 'PATCH'] + .map( + (part) => + header.match( + new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`), + )?.[1], + ) + .join('.'); + if (headerVersion === process.versions.node) { + childEnv.npm_config_nodedir = prefix; + childEnv.npm_config_force_process_config = 'true'; + } + const run = (args) => + runRepair('pnpm', args, { + cwd: workspace, + env: childEnv, + signal, + timeoutMs: small ? 1800000 : 600000, + }); + const version = await run(['--version']); + if (version.trim() !== '8.3.1') + throw new Error('Dependency repair requires existing pnpm 8.3.1'); + await run([ + 'install', + '--prod', + '--frozen-lockfile', + '--ignore-scripts', + '--child-concurrency=1', + '--network-concurrency=2', + ]); + await run(['rebuild', 'sqlite3', '@whyour/sqlite3']); + if ( + !(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals( + await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')), + ) + ) + throw new Error('Dependency repair changed the frozen lockfile'); + await fs.rm(path.join(source, 'node_modules'), { + recursive: true, + force: true, + }); + await fs.rename( + path.join(workspace, 'node_modules'), + path.join(source, 'node_modules'), + ); + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} + +// Bound output, wall time and the entire npm/node-gyp process group. +async function runRepair(program, args, options) { + return new Promise((resolve, reject) => { + options.signal?.throwIfAborted(); + let output = '', + stdout = '', + failure, + killTimer; + const child = spawn(program, args, { + cwd: options.cwd, + env: options.env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const kill = (sig) => { + try { + if (child.pid && process.platform !== 'win32') + process.kill(-child.pid, sig); + else child.kill(sig); + } catch {} + }; + const stop = (error) => { + failure ||= error; + kill('SIGTERM'); + killTimer ||= setTimeout(() => kill('SIGKILL'), 1500); + }; + const abort = () => + stop(options.signal.reason || new Error('Dependency repair aborted')); + const timer = setTimeout( + () => stop(new Error('Dependency repair timed out')), + options.timeoutMs, + ); + const capture = (chunk) => { + output = (output + chunk).slice(-32768); + }; + child.stdout.on('data', (chunk) => { + stdout = (stdout + chunk).slice(-32768); + capture(chunk); + }); + child.stderr.on('data', capture); + const cleanup = () => { + clearTimeout(timer); + clearTimeout(killTimer); + options.signal?.removeEventListener('abort', abort); + kill('SIGKILL'); + }; + child.on('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', () => kill('SIGKILL')); + child.on('close', (code, signal) => { + cleanup(); + if (failure || code !== 0) + reject( + failure || + new Error( + `Dependency repair failed (${signal || code}): ${output}`, + ), + ); + else resolve(stdout); + }); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + }); +} + +module.exports = { + prestartBackend, + dependencyFailure, + repairDependencies, + probeLimits, +}; diff --git a/cli/test/internal/local.test.cjs b/cli/test/internal/local.test.cjs index 4d4438dc..047a716e 100644 --- a/cli/test/internal/local.test.cjs +++ b/cli/test/internal/local.test.cjs @@ -322,6 +322,10 @@ test( async (t) => { if (process.platform === 'win32') return t.skip('POSIX process groups'); const { runProcess } = require('../../dist/internal/runtime/process'); + const marker = path.join(sandbox(t), 'timeout'); + const controller = new AbortController(); + const guard = setTimeout(() => controller.abort(), 4000); + t.after(() => clearTimeout(guard)); let descendant; t.after(() => { if (descendant) @@ -329,13 +333,17 @@ test( process.kill(descendant, 'SIGKILL'); } catch {} }); + // Start the deadline only after the descendant installed its handlers. + // Cold Node startup under parallel load need not fit in 350 milliseconds. const grandchild = - 'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)'; + 'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)'; const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify( grandchild, - )}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`; + )}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`; const result = await runProcess(process.execPath, ['-e', parent], { timeoutMs: 350, + timeoutControl: { marker }, + signal: controller.signal, graceMs: 100, output: (chunk) => { const pid = Number(chunk.toString().trim()); @@ -345,6 +353,7 @@ test( assert.equal(result.code, 124); assert.equal(result.timedOut, true); assert.ok(descendant); + assert.equal(fs.existsSync(marker), true); }, ); diff --git a/cli/test/internal/operator.test.cjs b/cli/test/internal/operator.test.cjs index 91c742fa..165a81e9 100644 --- a/cli/test/internal/operator.test.cjs +++ b/cli/test/internal/operator.test.cjs @@ -20,6 +20,40 @@ const { sourceCommit, } = require('../../../test/helpers/upgrade-fixture.cjs'); +test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => { + const ctx = await fixture(t); + ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl'); + ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz'; + ctx.env.QL_UPGRADE_REPAIR_DEPS = '1'; + ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345'; + ctx.env.QA_USER_SETTING = 'preserved'; + ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + await stub( + ctx, + 'pm2', + `require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({ + file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null, + launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING, + proxy:process.env.HTTPS_PROXY, + })+'\\n');`, + ); + await startPanel(ctx); + for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8')) + .trim() + .split('\n') + .map(JSON.parse)) + assert.deepEqual(runtime, { + file: null, + repair: null, + launcher: null, + user: 'preserved', + proxy: 'http://127.0.0.1:7890', + }); + assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz'); + assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1'); + assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345'); +}); + async function fixture(t) { t.mock.method( require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), 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..02d3f244 --- /dev/null +++ b/cli/test/internal/preparedUpgrade.test.cjs @@ -0,0 +1,99 @@ +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 status of ['ready', 'failed', 'completed', 'absent']) + for (const wrongVersion of [false, true]) + test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + await fs.writeFile( + path.join(f.root, 'package.json'), + '{"qinglongUpgradeFormat":2}', + ); + const file = path.join(f.tmp, 'panel-upgrade.json'); + if (status === 'absent') await fs.unlink(file); + else + await fs.writeFile( + file, + JSON.stringify({ + ...(await f.state()), + status, + error: 'old error', + version: '9.9.9', + sourceCommit: 'f'.repeat(40), + }), + ); + t.mock.method(operator, 'stopPanel', async () => {}); + t.mock.method(operator, 'startPanel', async () => ({ + manager: 'fixture', + })); + t.mock.method(artifacts, 'waitForHealth', async () => {}); + const operation = reloadPanel( + createContext({ root: f.root }, f.env), + 'system', + ); + if (wrongVersion) + await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + const state = await f.state(); + assert.equal(state.status, wrongVersion ? 'failed' : 'completed'); + assert.equal(state.sourceCommit, f.expected.sourceCommit); + assert.equal(state.version, f.expected.version); + assert.notEqual(state.error, 'old error'); + 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', + ); + }); + +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..e80c731f --- /dev/null +++ b/docker/Dockerfile.armv6-glibc @@ -0,0 +1,46 @@ +# ARMv7 is a source of application files only. None of its system libraries or +# Node binaries are executed or copied into the ARMv6 runtime. +ARG SOURCE_IMAGE +FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source + +# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers. +FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime +COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y --no-install-recommends \ + debootstrap gpg gpgv ca-certificates curl qemu-user-static && \ + rm -rf /var/lib/apt/lists/* +# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest +# access checks. Pin the upstream static tracer instead of misresolving paths. +RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \ + echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \ + chmod 755 /usr/local/bin/proot && proot --version +RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \ + echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \ + gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \ + debootstrap --foreign --arch=armhf --variant=minbase \ + --keyring=/usr/share/keyrings/raspbian.gpg \ + bullseye /armv6 https://archive.raspbian.org/raspbian +COPY docker/armv6-run /usr/local/bin/armv6-run +RUN chmod 755 /usr/local/bin/armv6-run && \ + DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \ + cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \ + printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \ + mkdir -p /armv6/etc/ssl/certs && \ + cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \ + printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \ + printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \ + chmod 755 /armv6/usr/sbin/policy-rc.d && \ + armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*' +# This Node is a pinned CI/test prerequisite, never part of the upgrade archive. +# Build against the minimum supported Node, including its bundled C++ headers. +RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \ + echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \ + tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz +COPY --from=source /ql/ /armv6/ql/ +# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries. +COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/ +RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \ + armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\''' +LABEL qinglong.qa.runtime="armv6-glibc-arm1176" +ENTRYPOINT ["/usr/local/bin/armv6-run"] diff --git a/docker/Dockerfile.upgrade-bundle 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..454cba57 --- /dev/null +++ b/docs/development/platform-upgrade-bundles.md @@ -0,0 +1,77 @@ +# 平台完整升级包 + +CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。 + +## 在线升级 + +`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。 + +面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。 + +正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行: + +```sh +gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true +``` + +手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v--`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。 + +开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。 + +版本入口为 GitHub prerelease `upgrade-v` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v--` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。 + +## 手动离线安装和升级 + +从 `upgrade-v` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v--` release。独立安装器已内置解析依赖,在目标设备正常情况无需 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 +``` + +仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如: + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps +``` + +修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。 + +修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。 + +通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。 + +手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。 + +重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。 + +安装到空目录: + +```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 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。 +- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 + +自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。 diff --git a/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..27bac8e3 --- /dev/null +++ b/scripts/build-upgrade-bundle.cjs @@ -0,0 +1,142 @@ +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, + version: manifest.version, + 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..7fc9b715 --- /dev/null +++ b/scripts/publish-upgrade-bundles.cjs @@ -0,0 +1,253 @@ +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, options = {}) { + const { version, releaseTag = `upgrade-${commit}` } = options; + if ( + !/^[a-f0-9]{40}$/.test(commit) || + !/^[\w.-]+\/[\w.-]+$/.test(repository) || + !/^[\w.+-]+$/.test(releaseTag) + ) + 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 || + (version !== undefined && record.version !== version) || + 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/${encodeURIComponent( + releaseTag, + )}/${filename}`, + }; + } + return { format: 2, sourceCommit: commit, packages }; +} +function publishBundles({ + directory, + commit, + repository, + branch, + version, + buildId, + allowDevelopment = false, + gh = (...args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), +}) { + if ( + branch !== 'master' && + !(branch === 'develop' && allowDevelopment === true) + ) + throw new Error( + 'Only master or explicitly requested development images publish bundles', + ); + if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || '')) + throw new Error('Invalid bundle version'); + if (!/^\d+-\d+$/.test(buildId || '')) + throw new Error('Invalid bundle build ID'); + // Rebuilding a version gets new download URLs, including when its commit is + // unchanged. Only the version/channel pointer is overwritten, never bytes + // that an in-flight download has already selected by hash. + const development = branch === 'develop'; + const tag = `upgrade-build-${ + development ? 'develop-' : '' + }v${version}-${buildId}`; + const index = { + ...releaseIndex(directory, commit, repository, { + version, + releaseTag: tag, + }), + version, + buildId, + channel: branch, + }; + 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/${encodeURIComponent( + tag, + )}/install-bundle.cjs`, + }; + fs.writeFileSync( + path.join(directory, 'latest.json'), + JSON.stringify(index) + '\n', + ); + const lookup = (ref) => { + try { + return JSON.parse( + gh( + 'api', + `repos/${repository}/releases/tags/${encodeURIComponent(ref)}`, + ), + ); + } catch (error) { + if (/\bHTTP 404\b/.test(String(error.stderr))) return null; + throw error; + } + }; + const notes = + `Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` + + Object.entries(index.packages) + .map(([target, entry]) => `- [${target}](${entry.url})`) + .join('\n') + + `\n- [Standalone offline installer](${index.installer.url})`; + const ensureRelease = (ref, title) => { + if (!lookup(ref)) + gh( + 'release', + 'create', + ref, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--latest=false', + '--title', + title, + '--notes', + notes, + ); + }; + const currentSource = () => + gh( + 'api', + `repos/${repository}/git/ref/heads/${branch}`, + '--jq', + '.object.sha', + ).trim() === commit; + if (!currentSource()) { + console.log('Newer source exists; leaving version and channel unchanged'); + return false; + } + ensureRelease(tag, `Platform upgrade v${version} (${buildId})`); + const assets = platforms + .flatMap((target) => { + const archive = path.join( + directory, + `qinglong-${commit}-${target}.tar.gz`, + ); + return [archive, archive + '.json']; + }) + .concat( + path.join(directory, 'install-bundle.cjs'), + path.join(directory, 'latest.json'), + ); + const published = lookup(tag); + const missing = []; + const matches = (asset, file) => { + const bytes = fs.readFileSync(file); + return ( + asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}` + ); + }; + for (const file of assets) { + const previous = published.assets.find( + (a) => a.name === path.basename(file), + ); + if (!previous) missing.push(file); + else if (!matches(previous, file)) + throw new Error( + `Immutable release asset differs: ${path.basename(file)}`, + ); + } + if (missing.length) + gh('release', 'upload', tag, ...missing, '--repo', repository); + const uploaded = lookup(tag); + if ( + !assets.every((file) => + matches( + uploaded.assets.find((a) => a.name === path.basename(file)), + file, + ), + ) + ) + throw new Error('Published platform bundle verification failed'); + + // Each channel serializes its image builds and pointer writes as one release. + // A rerun of an earlier workflow is allowed to replace the same + // version/commit; an obsolete source commit must never advance either alias. + const aliases = development + ? ['upgrade-develop'] + : [`upgrade-v${version}`, 'upgrade-master']; + for (const ref of aliases) { + if (!currentSource()) { + console.log('Newer source exists; leaving version and channel unchanged'); + return false; + } + ensureRelease( + ref, + ref === `upgrade-${branch}` + ? `Upgrade channel ${branch}` + : `Platform upgrade v${version}`, + ); + gh( + 'release', + 'upload', + ref, + path.join(directory, 'latest.json'), + '--repo', + repository, + '--clobber', + ); + gh('release', 'edit', ref, '--repo', repository, '--notes', notes); + } + return true; +} +module.exports = { releaseIndex, platforms, publishBundles }; +if (require.main === module) { + const [directory, commit, repository, branch] = process.argv.slice(2); + const version = require('../package.json').version; + if ( + require('js-yaml').load( + fs.readFileSync(path.join(__dirname, '../version.yaml'), 'utf8'), + ).version !== version + ) + throw new Error('Package and image release versions differ'); + publishBundles({ + directory, + commit, + repository, + branch, + version, + buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, + allowDevelopment: + process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' && + process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true', + }); +} diff --git a/shell/install-bundle.cjs b/shell/install-bundle.cjs new file mode 100644 index 00000000..3473b77a --- /dev/null +++ b/shell/install-bundle.cjs @@ -0,0 +1,320 @@ +#!/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' }, + 'repair-deps': { 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] [--repair-deps]', + ); + 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), + }; + } + environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0'; + 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 (recover) => { + await report({ + status: 'restarting', + phase: 'restarting', + }); + await hook( + 'stop', + [], + recover ? undefined : 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/components/UpgradeNotice.tsx b/src/components/UpgradeNotice.tsx index d3365e26..facd3d2b 100644 --- a/src/components/UpgradeNotice.tsx +++ b/src/components/UpgradeNotice.tsx @@ -2,7 +2,10 @@ import { CloudDownloadOutlined } from '@ant-design/icons'; import { Modal, Tooltip } from 'antd'; import { useState } from 'react'; import intl from 'react-intl-universal'; -import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade'; +import useUpgrade, { + activeStatuses, + upgradeStatusLabel, +} from '@/hooks/useUpgrade'; import CheckUpdate from '@/pages/setting/checkUpdate'; export default function UpgradeNotice({ @@ -21,16 +24,15 @@ export default function UpgradeNotice({ if (!showNotice && !open) return null; const text = !connected ? intl.get('连接已断开,正在重新连接') - : job.status === 'failed' - ? intl.get('更新失败') - : intl.get(phaseLabels[job.phase || ''] || '检查更新'); + : intl.get(upgradeStatusLabel(job)); const label = + activeStatuses.includes(job.status) && job.phase === 'downloading' && job.percent != null ? `${text} ${job.percent}%` : text; return ( <> - {showNotice && ( + {showNotice && !open && (