From bbb3dbf32be8aa200f29bf2030ce63c9611e1aea Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 18 Oct 2026 16:00:26 +0800 Subject: [PATCH] feat: ship preflight-verified online and offline platform upgrades Build complete platform tar.gz bundles with production dependencies for 13 Linux architecture/libc targets, including ARMv6 glibc and musl. Share archive, payload, runtime and dependency verification across online preparation and manual offline installation while retaining the existing Node runtime. Publish readiness only after an isolated backend starts successfully. Repair verified dependency failures once with the frozen lockfile; offline repair requires explicit opt-in. Preserve configured proxies and bound memory, concurrency, process lifetimes and cleanup on small devices. Verify the running build after explicit restart, restore prior application files and dependencies on failure, and retain backups when the failed service cannot stop. Complete cancellation recovery outside the aborted signal, reap descendants as their supervisor exits, and keep operation-only upgrade variables out of both Bash and CLI service startup environments. Keep dialog/sidebar states consistent, hide completed notices, and retain menu/user controls in narrow desktop viewports with long-name truncation. Validation: 791 backend/frontend tests passed (8 platform skips), 294 CLI tests passed (5 platform skips), backend/CLI type checks and standalone offline installer build. On dev, 29 additional Linux fault regressions passed without external networking, including real Node/PM2 SIGTERM rollback. Earlier full bundle application and ARM1176/glibc prestart acceptance remain recorded. Make the mirror notification timing regression respect Bash SECONDS integer resolution: require two clock ticks and assert the logged retry threshold. Validated with Node 20 full regression and 12 repeated boundary runs. --- .github/workflows/build-docker-image.yml | 99 +++ back/app.ts | 39 +- back/bootstrap/upgradeProbe.ts | 112 +++ back/loaders/httpApplication.ts | 33 + back/services/grpc.ts | 4 +- cli/package-lock.json | 78 +- cli/package.json | 3 +- cli/scripts/build.cjs | 15 + cli/src/internal/maintenance/operator.ts | 8 + cli/src/internal/maintenance/panelUpgrade.cjs | 90 +- .../internal/maintenance/platformBundle.cjs | 579 +++++++++++++ cli/src/internal/maintenance/upgrade.ts | 37 +- .../internal/maintenance/upgradeArtifacts.cjs | 118 ++- .../internal/maintenance/upgradePrestart.cjs | 393 +++++++++ cli/test/internal/operator.test.cjs | 34 + cli/test/internal/platformBundle.test.cjs | 35 + cli/test/internal/preparedUpgrade.test.cjs | 51 ++ docker/Dockerfile.armv6-glibc | 46 + docker/Dockerfile.upgrade-bundle | 30 + docker/Dockerfile.upgrade-bundle-armv6-glibc | 13 + docker/armv6-run | 7 + docs/development/platform-upgrade-bundles.md | 65 ++ package.json | 6 +- pnpm-lock.yaml | 8 +- scripts/build-bundle-installer.cjs | 22 + scripts/build-upgrade-bundle.cjs | 141 +++ scripts/publish-upgrade-bundles.cjs | 153 ++++ shell/install-bundle.cjs | 320 +++++++ shell/start-simplify.sh | 11 + src/components/UpgradeNotice.tsx | 12 +- src/hooks/useUpgrade.ts | 15 + src/layouts/index.less | 5 + src/locales/en-US.json | 3 + src/locales/zh-CN.json | 3 + src/pages/setting/checkUpdate.tsx | 15 +- src/utils/hooks.ts | 46 +- test/back/armv6-worker-readiness.test.cjs | 101 +++ test/back/manual-upgrade-state.test.cjs | 70 ++ test/back/offline-upgrade-recovery.test.cjs | 91 ++ test/back/panel-upgrade.test.cjs | 25 +- test/back/platform-bundle.test.cjs | 814 ++++++++++++++++++ test/back/role-loading.test.cjs | 30 + .../start-certificate-preparation.test.cjs | 53 ++ test/back/static-mirror-trigger.test.cjs | 13 +- test/back/upgrade-guards.test.cjs | 51 ++ test/back/upgrade-lifecycle.test.cjs | 43 + test/back/upgrade-prestart.test.cjs | 350 ++++++++ test/front/responsive-layout.test.cjs | 106 +++ test/front/update-progress.test.cjs | 39 +- test/helpers/manual-upgrade-fixture.cjs | 134 +++ 50 files changed, 4449 insertions(+), 120 deletions(-) create mode 100644 back/bootstrap/upgradeProbe.ts create mode 100644 back/loaders/httpApplication.ts create mode 100644 cli/src/internal/maintenance/platformBundle.cjs create mode 100644 cli/src/internal/maintenance/upgradePrestart.cjs create mode 100644 cli/test/internal/platformBundle.test.cjs create mode 100644 cli/test/internal/preparedUpgrade.test.cjs create mode 100644 docker/Dockerfile.armv6-glibc create mode 100644 docker/Dockerfile.upgrade-bundle create mode 100644 docker/Dockerfile.upgrade-bundle-armv6-glibc create mode 100755 docker/armv6-run create mode 100644 docs/development/platform-upgrade-bundles.md create mode 100644 scripts/build-bundle-installer.cjs create mode 100644 scripts/build-upgrade-bundle.cjs create mode 100644 scripts/publish-upgrade-bundles.cjs create mode 100644 shell/install-bundle.cjs create mode 100644 test/back/armv6-worker-readiness.test.cjs create mode 100644 test/back/manual-upgrade-state.test.cjs create mode 100644 test/back/offline-upgrade-recovery.test.cjs create mode 100644 test/back/platform-bundle.test.cjs create mode 100644 test/back/start-certificate-preparation.test.cjs create mode 100644 test/back/upgrade-prestart.test.cjs create mode 100644 test/front/responsive-layout.test.cjs create mode 100644 test/helpers/manual-upgrade-fixture.cjs diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index eb05f9c0..b92ac8d4 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" @@ -203,6 +206,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 +273,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 +295,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 +361,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 +572,93 @@ jobs: echo '::error::Could not check the published npm version; refusing to publish.' exit 1 fi + + build-upgrade-bundles: + needs: [build-alpine, build-debian] + runs-on: ubuntu-22.04 + timeout-minutes: 90 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - { platform: linux/amd64, libc: musl, name: x64-musl } + - { platform: linux/arm/v6, libc: musl, name: armv6-musl } + - { platform: linux/arm/v7, libc: musl, name: armv7-musl } + - { platform: linux/arm64, libc: musl, name: arm64-musl } + - { platform: linux/ppc64le, libc: musl, name: ppc64-musl } + - { platform: linux/s390x, libc: musl, name: s390x-musl } + - { platform: linux/386, libc: musl, name: ia32-musl } + - { platform: linux/amd64, libc: glibc, name: x64-glibc } + - { platform: linux/arm/v6, libc: glibc, name: armv6-glibc } + - { platform: linux/arm/v7, libc: glibc, name: armv7-glibc } + - { platform: linux/arm64, libc: glibc, name: arm64-glibc } + - { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc } + - { platform: linux/s390x, libc: glibc, name: s390x-glibc } + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v4 + with: + cache-image: false + - uses: docker/setup-buildx-action@v4 + - name: Build and probe the immutable platform package + env: + ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }} + DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }} + GH_TOKEN: ${{ github.token }} + BUNDLE_LIBC: ${{ matrix.libc }} + BUNDLE_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + digest="$ALPINE_DIGEST" + if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi + test -n "$digest" + image="ghcr.io/${GITHUB_REPOSITORY}@${digest}" + if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then + # This amd64 helper executes a genuine Raspbian ARMv6 userspace under + # ARM1176 QEMU. It is a CI validator, not a user runtime or bundle. + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}" + mkdir -p output/validator + docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json . + validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)" + docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade . + else + docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade . + fi + - uses: actions/upload-artifact@v6 + with: + name: upgrade-${{ matrix.name }}-${{ github.sha }} + path: output/upgrade/* + if-no-files-found: error + + publish-upgrade-bundles: + needs: build-upgrade-bundles + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: upgrade-channel-${{ github.ref_name }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - run: pnpm install --prod --frozen-lockfile + - run: npm --prefix cli ci + - uses: actions/download-artifact@v7 + with: + pattern: upgrade-*-${{ github.sha }} + path: output/upgrade + merge-multiple: true + - run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs + - name: Publish complete packages before advancing the channel + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" diff --git a/back/app.ts b/back/app.ts index 8a195c00..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..27d3b345 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -6,6 +6,7 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); const artifacts = require('./upgradeArtifacts.cjs'); +const { nodeRuntimeArgs } = require('./platformBundle.cjs'); const active = new Set(['starting', 'running', 'restarting']); const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); @@ -200,6 +201,63 @@ async function getStatus(tmp) { return state; } +// A shell/CLI restart can consume a bundle prepared by the offline installer +// or panel. Keep that existing job honest without creating jobs for legacy +// reloads which have no panel readiness record. +async function withPreparedJob(tmp, env, selected, operation) { + const previous = await getStatus(tmp); + if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。'); + if (previous.status !== 'ready') return operation(); + return artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + async () => { + const current = await getStatus(tmp); + if (active.has(current.status)) + throw new Error('已有更新或重载正在执行。'); + if (current.status !== 'ready') return operation(); + assertSelected(current, await stagedMetadata(selected)); + const previousBuild = await readRunningBuild(env).catch(() => undefined); + const { log, ...record } = current; + let state = { + ...record, + id: randomUUID(), + action: 'apply', + status: 'running', + phase: 'verifying', + startedAt: Date.now(), + pid: process.pid, + }; + const report = async (patch) => { + state = { ...state, ...patch, updatedAt: Date.now() }; + await save(tmp, state); + }; + await fs.writeFile( + logFile(tmp), + 'Manual application of prepared update\n', + { + mode: 0o600, + }, + ); + await report({}); + try { + const result = await operation({ + onStop: () => report({ status: 'restarting', phase: 'restarting' }), + onStart: (recover) => + recover + ? undefined + : verifyRunningVersion(env, state, previousBuild), + }); + await report({ status: 'completed', phase: 'completed' }); + return result; + } catch (error) { + await report({ status: 'failed', error: error.message }); + throw error; + } + }, + env, + ); +} + async function startJob({ root, tmp, env = process.env }, action) { if (!['download', 'apply'].includes(action)) throw new Error('Invalid upgrade action'); @@ -220,12 +278,14 @@ async function startJob({ root, tmp, env = process.env }, action) { let selected; let metadata; if (action === 'apply') { - // Fail before launching a process or stopping the working panel. + // Keep requests short on slow devices. The detached worker performs + // complete file/dependency verification before it can stop the panel. try { selected = await artifacts.selectedUpgrade( tmp, artifacts.releaseBranch(env), env, + { metadataOnly: true }, ); metadata = await stagedMetadata(selected); if (previous.status === 'ready') @@ -390,7 +450,7 @@ async function runJob({ root, tmp, env = process.env }, id) { ); await artifacts.waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); if (!recover) @@ -429,16 +489,30 @@ async function runJob({ root, tmp, env = process.env }, id) { } } -module.exports = { getStatus, startJob, runJob }; +module.exports = { + getStatus, + saveState: save, + startJob, + runJob, + withPanelLifecycle, + readRunningBuild, + verifyRunningVersion, + stagedMetadata, + withPreparedJob, +}; async function launchWorker(id) { // detached creates a session but keeps the original parent. PM2 kills the // backend's entire descendant tree, so an intermediate launcher must exit // before the worker is allowed to stop that backend. - const child = spawn(process.execPath, [__filename, 'worker', id], { - env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, - detached: true, - stdio: 'inherit', - }); + const child = spawn( + process.execPath, + [...nodeRuntimeArgs(), __filename, 'worker', id], + { + env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, + detached: true, + stdio: 'inherit', + }, + ); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); diff --git a/cli/src/internal/maintenance/platformBundle.cjs b/cli/src/internal/maintenance/platformBundle.cjs new file mode 100644 index 00000000..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..6218af27 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,38 @@ async function main() { if (process.argv[4] !== 'true') return; } else if (process.argv[2] !== 'reload') throw new Error('Unknown upgrade command.'); - await reloadSystem({ - root, - tmp, - env, - data: env.dir_data || path.join(root, 'data'), - staticRoot: env.dir_static || path.join(root, 'static'), - config: env.dir_config || path.join(root, 'data/config'), - lifecycle, - }); + const apply = async (preparedJob) => { + job = preparedJob; + await reloadSystem({ + root, + tmp, + env, + data: env.dir_data || path.join(root, 'data'), + staticRoot: env.dir_static || path.join(root, 'static'), + config: env.dir_config || path.join(root, 'data/config'), + lifecycle, + }); + }; + // Older installations can carry just this self-contained Bash verifier. + // Load panel job dependencies only when there is a panel job to reconcile. + const hasPanelJob = await fs + .access(path.join(tmp, 'panel-upgrade.json')) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + if (hasPanelJob) { + const selected = await selectedUpgrade(tmp, releaseBranch(env), env, { + metadataOnly: true, + }); + await require('./panelUpgrade.cjs').withPreparedJob( + tmp, + env, + selected, + apply, + ); + } else await apply(); console.log(upgradeMessage('更新及启动检查成功...', env)); }), env, @@ -875,6 +956,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/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..b1564868 --- /dev/null +++ b/cli/test/internal/preparedUpgrade.test.cjs @@ -0,0 +1,51 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { + manualUpgradeFixture, +} = require('../../../test/helpers/manual-upgrade-fixture.cjs'); +const { createContext } = require('../../dist/internal/runtime/context'); +const { reloadPanel } = require('../../dist/internal/maintenance/upgrade'); +const operator = require('../../dist/internal/maintenance/operator'); +const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs'); + +for (const wrongVersion of [false, true]) { + test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + const context = createContext({ root: f.root }, f.env); + const timeouts = []; + t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => { + timeouts.push(timeout); + }); + t.mock.method(operator, 'stopPanel', async () => { + assert.equal((await f.state()).status, 'restarting'); + }); + t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' })); + const operation = reloadPanel(context, 'system'); + if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + assert.ok(timeouts.length > 0); + assert.ok( + timeouts.every((timeout) => timeout === undefined), + 'use the shared ARMv6/default startup deadline', + ); + assert.equal( + (await f.state()).status, + wrongVersion ? 'failed' : 'completed', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'), + wrongVersion ? 'old backend' : 'new backend', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'), + 'preserve data', + ); + if (wrongVersion) + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'), + 'old dependency', + ); + }); +} diff --git a/docker/Dockerfile.armv6-glibc b/docker/Dockerfile.armv6-glibc new file mode 100644 index 00000000..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..9253fb62 --- /dev/null +++ b/docs/development/platform-upgrade-bundles.md @@ -0,0 +1,65 @@ +# 平台完整升级包 + +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 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。 + +渠道索引为 GitHub prerelease `upgrade-develop` 或 `upgrade-master` 下的 `latest.json`。索引记录准确字节数、SHA256 和不可变 `upgrade-` release 资产 URL。13 个平台均完成归档、解包后复验及依赖试运行才发布渠道;过期工作流不能覆盖更新提交的渠道。 + +## 手动离线安装和升级 + +从同一个 `upgrade-` release 下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同 release 的 `latest.json` 核对归档 SHA256。 + +已有安装(默认应用并重启): + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/qinglong--linux-arm64-musl.tar.gz +``` + +只准备,稍后手动重启: + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only +ql reload system +``` + +仅在明确允许联网修复时添加 `--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..034d77af --- /dev/null +++ b/scripts/build-upgrade-bundle.cjs @@ -0,0 +1,141 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tar = require('tar'); +const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const { + verifyUpgrade, +} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); + +async function normalizeModes(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await fs.chmod(filename, 0o755); + await normalizeModes(filename); + } else if (entry.isFile()) { + const stat = await fs.stat(filename); + await fs.chmod(filename, 0o644 | (stat.mode & 0o111)); + } + } +} + +async function buildBundle(root, output) { + root = path.resolve(root); + output = path.resolve(output); + const target = await bundle.runtimeTarget(); + if (process.platform !== 'linux') + throw new Error('Release bundles must be built and probed on Linux'); + const build = await verifyUpgrade(root, path.join(root, 'static')); + await bundle.smokeDependencies(root); + await fs.mkdir(output, { recursive: true }); + const workspace = await fs.mkdtemp(path.join(output, '.bundle-')); + try { + const payload = path.join(workspace, 'qinglong'); + const source = path.join(payload, 'source'), + staticRoot = path.join(payload, 'static'); + await fs.mkdir(source, { recursive: true }); + const excluded = new Set([ + '.git', + '.tmp', + '.env', + 'data', + 'static', + 'output', + 'node_modules', + ]); + for (const name of await fs.readdir(root)) { + if (excluded.has(name)) continue; + await fs.cp(path.join(root, name), path.join(source, name), { + recursive: true, + verbatimSymlinks: true, + }); + } + await fs.cp( + path.join(root, 'node_modules'), + path.join(source, 'node_modules'), + { recursive: true, verbatimSymlinks: true }, + ); + await fs.cp(path.join(root, 'static'), staticRoot, { + recursive: true, + verbatimSymlinks: true, + }); + await normalizeModes(source); + await normalizeModes(staticRoot); + const manifest = { + format: 2, + sourceCommit: build.sourceCommit, + version: JSON.parse( + await fs.readFile(path.join(root, 'package.json'), 'utf8'), + ).version, + lockfileSha256: build.lockfileSha256, + target: { + platform: target.platform, + arch: target.arch, + libc: target.libc, + libcMinimum: target.libcVersion, + endianness: target.endianness, + armFloatAbi: target.armFloatAbi, + }, + nodeMinimum: '20.18.1', + nodeApi: 6, + files: await bundle.inventory({ source, static: staticRoot }), + }; + await fs.writeFile( + path.join(payload, 'bundle.json'), + JSON.stringify(manifest), + ); + console.log('Bundle inventory verified; writing archive'); + const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey( + target, + )}.tar.gz`; + await tar.c( + { + gzip: true, + portable: true, + noMtime: true, + cwd: workspace, + file: path.join(output, filename), + }, + ['qinglong'], + ); + // Verify the bytes users receive, not just the pre-archive build directory. + const roundtrip = path.join(workspace, 'roundtrip'); + await fs.mkdir(roundtrip); + console.log('Verifying archived paths, permissions and checksums'); + await bundle.extractArchive(path.join(output, filename), roundtrip); + const extracted = { + source: path.join(roundtrip, 'qinglong/source'), + static: path.join(roundtrip, 'qinglong/static'), + }; + await bundle.verifyPayload(extracted, manifest, target); + await verifyUpgrade(extracted.source, extracted.static); + await bundle.smokeDependencies(extracted.source); + console.log('Archived production dependencies and SQLite verified'); + const bytes = await fs.readFile(path.join(output, filename)); + const record = { + format: 2, + sourceCommit: build.sourceCommit, + target: bundle.targetKey(target), + filename, + baseImage: process.env.QL_BUNDLE_BASE_IMAGE, + size: bytes.length, + sha256: bundle.sha256(bytes), + }; + await fs.writeFile( + path.join(output, filename + '.json'), + JSON.stringify(record) + '\n', + ); + console.log(JSON.stringify(record)); + return record; + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} +module.exports = { buildBundle, normalizeModes }; +if (require.main === module) + buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch( + (e) => { + console.error(e); + process.exitCode = 1; + }, + ); diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs new file mode 100644 index 00000000..d323cdeb --- /dev/null +++ b/scripts/publish-upgrade-bundles.cjs @@ -0,0 +1,153 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { + sha256, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const platforms = [ + 'linux-x64-musl', + 'linux-armv6-musl', + 'linux-armv7-musl', + 'linux-arm64-musl', + 'linux-ppc64-musl', + 'linux-s390x-musl', + 'linux-ia32-musl', + 'linux-x64-glibc', + 'linux-armv6-glibc', + 'linux-armv7-glibc', + 'linux-arm64-glibc', + 'linux-ppc64-glibc', + 'linux-s390x-glibc', +]; +function releaseIndex(directory, commit, repository) { + if (!/^[a-f0-9]{40}$/.test(commit) || !/^[\w.-]+\/[\w.-]+$/.test(repository)) + throw new Error('Invalid release source'); + const packages = {}; + for (const target of platforms) { + const filename = `qinglong-${commit}-${target}.tar.gz`; + const record = JSON.parse( + fs.readFileSync(path.join(directory, filename + '.json')), + ); + const bytes = fs.readFileSync(path.join(directory, filename)); + if ( + record.format !== 2 || + record.sourceCommit !== commit || + record.target !== target || + record.filename !== filename || + record.size !== bytes.length || + record.sha256 !== sha256(bytes) + ) + throw new Error(`Unverified platform bundle: ${target}`); + packages[target] = { + size: record.size, + sha256: record.sha256, + url: `https://github.com/${repository}/releases/download/upgrade-${commit}/${filename}`, + }; + } + return { format: 2, sourceCommit: commit, packages }; +} +module.exports = { releaseIndex, platforms }; +if (require.main === module) { + const [directory, commit, repository, branch] = process.argv.slice(2); + const index = releaseIndex(directory, commit, repository); + const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs')); + index.installer = { + size: installer.length, + sha256: sha256(installer), + url: `https://github.com/${repository}/releases/download/upgrade-${commit}/install-bundle.cjs`, + }; + if (!['develop', 'master'].includes(branch)) + throw new Error('Invalid release channel'); + fs.writeFileSync( + path.join(directory, 'latest.json'), + JSON.stringify(index) + '\n', + ); + const gh = (...args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const tag = `upgrade-${commit}`; + try { + gh('release', 'view', tag, '--repo', repository); + } catch { + gh( + 'release', + 'create', + tag, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--title', + `Platform upgrade ${commit.slice(0, 12)}`, + '--notes', + 'Verified platform bundles with production dependencies. Existing Node runtime is retained.', + ); + } + const assets = fs + .readdirSync(directory) + .filter((n) => /\.(?:tar\.gz|cjs|json)$/.test(n) && n !== 'latest.json') + .map((n) => path.join(directory, n)); + const published = JSON.parse( + gh('api', `repos/${repository}/releases/tags/${tag}`), + ); + const missing = []; + for (const file of [...assets, path.join(directory, 'latest.json')]) { + const bytes = fs.readFileSync(file); + const previous = published.assets.find( + (a) => a.name === path.basename(file), + ); + if (!previous) missing.push(file); + else if ( + previous.size !== bytes.length || + previous.digest !== `sha256:${sha256(bytes)}` + ) + throw new Error( + `Immutable release asset differs: ${path.basename(file)}`, + ); + } + if (missing.length) + gh('release', 'upload', tag, ...missing, '--repo', repository); + // An older, slower run may publish immutable assets, but must not regress a channel. + if ( + gh( + 'api', + `repos/${repository}/git/ref/heads/${branch}`, + '--jq', + '.object.sha', + ).trim() !== commit + ) { + console.log('Newer source exists; leaving channel unchanged'); + } else { + const channel = `upgrade-${branch}`; + try { + gh('release', 'view', channel, '--repo', repository); + } catch { + gh( + 'release', + 'create', + channel, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--title', + `Upgrade channel ${branch}`, + '--notes', + 'The latest.json asset selects immutable, fully verified platform bundles.', + ); + } + gh( + 'release', + 'upload', + channel, + path.join(directory, 'latest.json'), + '--repo', + repository, + '--clobber', + ); + } +} diff --git a/shell/install-bundle.cjs b/shell/install-bundle.cjs new file mode 100644 index 00000000..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 && (