From e8b6a30bd968334786f511330aa524d438c9cad0 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 18 Oct 2026 22:19:49 +0800 Subject: [PATCH] feat: ship verified platform upgrades with channel bundle releases Package production dependencies per architecture for online and offline upgrades. Validate dependencies and isolated backend startup before readiness, repair once when permitted, and preserve service/data through failed preparation or rollback. Publish release bundles with master images and expose an opt-in develop build sharing the development index across Alpine and Debian images. --- .github/workflows/build-docker-image.yml | 115 +++ 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 | 120 ++- .../internal/maintenance/platformBundle.cjs | 579 +++++++++++++ cli/src/internal/maintenance/upgrade.ts | 37 +- .../internal/maintenance/upgradeArtifacts.cjs | 128 ++- .../internal/maintenance/upgradePrestart.cjs | 393 +++++++++ cli/test/internal/local.test.cjs | 13 +- cli/test/internal/operator.test.cjs | 34 + cli/test/internal/platformBundle.test.cjs | 35 + cli/test/internal/preparedUpgrade.test.cjs | 99 +++ 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 | 77 ++ package.json | 6 +- pnpm-lock.yaml | 8 +- scripts/build-bundle-installer.cjs | 22 + scripts/build-upgrade-bundle.cjs | 142 +++ scripts/publish-upgrade-bundles.cjs | 253 ++++++ 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 | 157 ++++ test/back/offline-upgrade-recovery.test.cjs | 91 ++ test/back/panel-upgrade.test.cjs | 25 +- test/back/platform-bundle.test.cjs | 814 ++++++++++++++++++ test/back/publish-upgrade-bundles.test.cjs | 398 +++++++++ test/back/role-loading.test.cjs | 30 + .../start-certificate-preparation.test.cjs | 53 ++ test/back/static-mirror-trigger.test.cjs | 13 +- test/back/upgrade-bundle-workflow.test.cjs | 109 +++ 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 +++ 53 files changed, 5271 insertions(+), 122 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/publish-upgrade-bundles.test.cjs create mode 100644 test/back/start-certificate-preparation.test.cjs create mode 100644 test/back/upgrade-bundle-workflow.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..f8c4b197 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -8,6 +8,9 @@ on: # CLI is verified separately; its shared Bash upgrade engine ships in images. - "!cli/**" - "cli/src/internal/maintenance/upgradeArtifacts.cjs" + - "cli/src/internal/maintenance/platformBundle.cjs" + - "cli/src/internal/maintenance/upgradePrestart.cjs" + - "cli/src/internal/maintenance/panelUpgrade.cjs" - "!.github/workflows/cli-package.yml" branches: - "master" @@ -16,6 +19,10 @@ on: - "v*" workflow_dispatch: inputs: + build_development_bundles: + description: Develop only - build and publish shared develop/debian-dev upgrade packages + type: boolean + default: false static_sync_only: description: Synchronize an existing static snapshot to GitLab and verify Gitee type: boolean @@ -33,6 +40,14 @@ on: permissions: contents: read +# Keep master image tags and their bundle indexes in one serialized release. +# A rerun may intentionally replace a version, including from the same commit. +# Manual development bundle releases are serialized independently of master. +# Ordinary develop pushes and Git-tag builds remain independent. +concurrency: + group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }} + cancel-in-progress: false + jobs: validate: if: ${{ !inputs.static_sync_only }} @@ -203,6 +218,8 @@ jobs: build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static + outputs: + image-digest: ${{ steps.image.outputs.digest }} runs-on: ubuntu-22.04 permissions: packages: write @@ -268,6 +285,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Build and push (Alpine) + id: image uses: docker/build-push-action@v7 with: build-args: | @@ -289,6 +307,8 @@ jobs: build-debian: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static + outputs: + image-digest: ${{ steps.image.outputs.digest }} runs-on: ubuntu-22.04 permissions: packages: write @@ -353,6 +373,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Build and push (Debian) + id: image uses: docker/build-push-action@v7 with: build-args: | @@ -563,3 +584,97 @@ jobs: echo '::error::Could not check the published npm version; refusing to publish.' exit 1 fi + + build-upgrade-bundles: + # Development packages are opt-in; both channels use this run's image digests. + if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }} + needs: [build-alpine, build-debian] + runs-on: ubuntu-22.04 + timeout-minutes: 90 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - { platform: linux/amd64, libc: musl, name: x64-musl } + - { platform: linux/arm/v6, libc: musl, name: armv6-musl } + - { platform: linux/arm/v7, libc: musl, name: armv7-musl } + - { platform: linux/arm64, libc: musl, name: arm64-musl } + - { platform: linux/ppc64le, libc: musl, name: ppc64-musl } + - { platform: linux/s390x, libc: musl, name: s390x-musl } + - { platform: linux/386, libc: musl, name: ia32-musl } + - { platform: linux/amd64, libc: glibc, name: x64-glibc } + - { platform: linux/arm/v6, libc: glibc, name: armv6-glibc } + - { platform: linux/arm/v7, libc: glibc, name: armv7-glibc } + - { platform: linux/arm64, libc: glibc, name: arm64-glibc } + - { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc } + - { platform: linux/s390x, libc: glibc, name: s390x-glibc } + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v4 + with: + cache-image: false + - uses: docker/setup-buildx-action@v4 + - name: Build and probe the immutable platform package + env: + ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }} + DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }} + GH_TOKEN: ${{ github.token }} + BUNDLE_LIBC: ${{ matrix.libc }} + BUNDLE_PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + digest="$ALPINE_DIGEST" + if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi + test -n "$digest" + image="ghcr.io/${GITHUB_REPOSITORY}@${digest}" + if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then + # This amd64 helper executes a genuine Raspbian ARMv6 userspace under + # ARM1176 QEMU. It is a CI validator, not a user runtime or bundle. + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}" + mkdir -p output/validator + docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json . + validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)" + docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade . + else + docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade . + fi + - uses: actions/upload-artifact@v6 + with: + name: upgrade-${{ matrix.name }}-${{ github.sha }} + path: output/upgrade/* + if-no-files-found: error + + publish-upgrade-bundles: + if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }} + needs: build-upgrade-bundles + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: upgrade-channel-${{ github.ref_name }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '20' + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - run: pnpm install --prod --frozen-lockfile + - run: npm --prefix cli ci + - uses: actions/download-artifact@v7 + with: + pattern: upgrade-*-${{ github.sha }} + path: output/upgrade + merge-multiple: true + - run: node scripts/build-bundle-installer.cjs output/upgrade/install-bundle.cjs + - name: Publish complete packages before advancing the channel + env: + GH_TOKEN: ${{ github.token }} + QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }} + run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" diff --git a/back/app.ts b/back/app.ts index 8a195c00..38821cc2 100644 --- a/back/app.ts +++ b/back/app.ts @@ -41,11 +41,18 @@ class Application { } private startMasterProcess() { + const readyTimeout = + process.arch === 'arm' && + Number( + (process.config.variables as { arm_version?: string | number }).arm_version, + ) === 6 + ? 120000 + : 30000; // Fork gRPC worker first and wait for it to be ready const grpcWorker = this.forkWorker('grpc'); // Wait for gRPC worker to signal it's ready before starting HTTP worker - this.waitForWorkerReady(grpcWorker, 30000) + this.waitForWorkerReady(grpcWorker, readyTimeout) .then(() => { Logger.info('[boot] gRPC worker is ready, starting HTTP worker'); this.httpWorker = this.forkWorker('http'); @@ -71,7 +78,7 @@ class Application { Logger.warn('Unable to notify HTTP worker of scheduler exit'); } const newGrpcWorker = this.forkWorker('grpc'); - this.waitForWorkerReady(newGrpcWorker, 30000) + this.waitForWorkerReady(newGrpcWorker, readyTimeout) .then(() => { Logger.info('gRPC worker restarted and ready'); // Re-register cron jobs by notifying the HTTP worker @@ -148,32 +155,8 @@ class Application { } private async setupMiddlewares(): Promise { - // Only the HTTP worker needs an Express application and its middleware. - const [ - { default: createExpress }, - { default: helmet }, - { default: cors }, - { default: compression }, - { monitoringMiddleware }, - ] = await Promise.all([ - import('express'), - import('helmet'), - import('cors'), - import('compression'), - import('./middlewares/monitoring'), - ]); - const app = createExpress(); - app.use((req, res, next) => { - if (req.query.t) delete req.query.t; - next(); - }); - app.use(helmet({ - contentSecurityPolicy: false, - })); - app.use(cors(config.cors)); - app.use(compression()); - app.use(monitoringMiddleware); - return app; + const { createHttpApplication } = await import('./loaders/httpApplication'); + return createHttpApplication(); } private setupMasterShutdown() { diff --git a/back/bootstrap/upgradeProbe.ts b/back/bootstrap/upgradeProbe.ts new file mode 100644 index 00000000..87f93782 --- /dev/null +++ b/back/bootstrap/upgradeProbe.ts @@ -0,0 +1,112 @@ +// A single, disposable backend process. Resolve startup dependencies, but never +// run appLoader: its initData/initTask hooks install dependencies and start user work. +import 'reflect-metadata'; +import dns from 'dns'; +import http from 'http'; + +async function probeUpgrade() { + if (!process.send || process.env.QL_UPGRADE_PROBE !== '1') + throw new Error('Upgrade probe requires its isolated supervisor'); + dns.setDefaultResultOrder('ipv4first'); + const { default: config } = await import('../config'); + const { sequelize } = await import('../data'); + const { default: loadDatabase } = await import('../loaders/db'); + const { Container } = await import('typedi'); + const { GrpcServerService } = await import('../services/grpc'); + const { HttpServerService } = await import('../services/http'); + const { createHttpApplication } = await import('../loaders/httpApplication'); + const { default: loadServer } = await import('../loaders/server'); + const { default: loadExpress } = await import('../loaders/express'); + await import('../loaders/app'); // Resolve normal startup hooks without running them. + const { default: inject } = await import('../loaders/depInjector'); + const grpc = Container.get(GrpcServerService); + const server = Container.get(HttpServerService); + try { + await loadDatabase(); + // Exercise an actual on-disk transaction as well as schema initialization. + await sequelize.transaction(async (transaction) => { + await sequelize.query('CREATE TABLE upgrade_probe (value INTEGER)', { + transaction, + }); + await sequelize.query('INSERT INTO upgrade_probe VALUES (42)', { + transaction, + }); + const [rows] = await sequelize.query('SELECT value FROM upgrade_probe', { + transaction, + }); + if ((rows[0] as { value: number })?.value !== 42) + throw new Error('Upgrade database readback failed'); + }); + inject(); + config.grpcPort = await grpc.initialize(); + const { default: cronClient } = await import('../schedule/client'); + cronClient.readiness.configure(async () => {}); + if (!(await cronClient.readiness.recover())) + throw new Error('Upgrade scheduler readiness failed'); + const app = await createHttpApplication(); + loadExpress({ app }); + const listener = await server.initialize(app, 0); + await loadServer({ server: listener }); + const address = listener.address(); + if (!address || typeof address === 'string') + throw new Error('No probe HTTP port'); + const health = await new Promise((resolve, reject) => { + const request = http.get( + `http://127.0.0.1:${address.port}/api/health`, + (response) => { + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('error', reject); + response.on('end', () => { + try { + const result = JSON.parse(body); + if ( + response.statusCode !== 200 || + result.code !== 200 || + result.data?.status !== 'ok' || + !result.data.services?.grpc || + !result.data.services?.http + ) + throw new Error('Upgrade HTTP/gRPC health check failed'); + resolve(result.data); + } catch (error) { + reject(error); + } + }); + }, + ); + request.setTimeout(10000, () => + request.destroy(new Error('Upgrade health request timed out')), + ); + request.on('error', reject); + }); + process.send!({ + type: 'upgrade-probe', + ok: true, + build: health.build, + memory: process.memoryUsage(), + pid: process.pid, + }); + } finally { + await server.shutdown(); + grpc.getServer().forceShutdown(); + await sequelize.close(); + } +} + +process.once('disconnect', () => process.exit(1)); +probeUpgrade() + .then(() => process.exit(0)) + .catch((error) => { + process.send?.({ + type: 'upgrade-probe', + ok: false, + code: error.code, + message: error.message, + stack: error.stack, + }); + console.error(error); + process.exit(1); + }); diff --git a/back/loaders/httpApplication.ts b/back/loaders/httpApplication.ts new file mode 100644 index 00000000..4d1cd98b --- /dev/null +++ b/back/loaders/httpApplication.ts @@ -0,0 +1,33 @@ +import type express from 'express'; +import config from '../config'; + +export async function createHttpApplication(): Promise { + // Only the HTTP worker needs an Express application and its middleware. + const [ + { default: createExpress }, + { default: helmet }, + { default: cors }, + { default: compression }, + { monitoringMiddleware }, + ] = await Promise.all([ + import('express'), + import('helmet'), + import('cors'), + import('compression'), + import('../middlewares/monitoring'), + ]); + const app = createExpress(); + app.use((req, res, next) => { + if (req.query.t) delete req.query.t; + next(); + }); + app.use( + helmet({ + contentSecurityPolicy: false, + }), + ); + app.use(cors(config.cors)); + app.use(compression()); + app.use(monitoringMiddleware); + return app; +} diff --git a/back/services/grpc.ts b/back/services/grpc.ts index c1c46c2b..4c667a8d 100644 --- a/back/services/grpc.ts +++ b/back/services/grpc.ts @@ -50,13 +50,13 @@ export class GrpcServerService { for (const host of hostsToTry) { try { const address = this.formatGrpcAddress(host, grpcPort); - await bindAsync(address, credentials); + const boundPort = await bindAsync(address, credentials); Logger.debug(`[boot] gRPC service started successfully on ${address}`); metricsService.record('grpc_service_start', 1, { port: grpcPort.toString(), host }); - return grpcPort; + return boundPort; } catch (err) { lastError = err as Error; Logger.warn(`Failed to bind gRPC on ${host}:${grpcPort}, trying next...`, err); diff --git a/cli/package-lock.json b/cli/package-lock.json index 1f8e5034..8ddc7b09 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@whyour/qinglong-cli", "version": "0.1.2", + "license": "Apache-2.0", "bin": { "ql": "dist/npm/ql.js" }, @@ -14,12 +15,12 @@ "@types/node": "22.19.0", "commander": "15.0.0", "esbuild": "0.28.2", + "tar": "7.5.20", "typescript": "5.2.2" }, "engines": { "node": ">=22.12.0" - }, - "license": "Apache-2.0" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", @@ -463,6 +464,19 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/node": { "version": "22.19.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", @@ -473,6 +487,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/commander": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", @@ -525,6 +549,46 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/tar": { + "version": "7.5.20", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", + "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", @@ -545,6 +609,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } } } } diff --git a/cli/package.json b/cli/package.json index 7185a111..a8c931de 100644 --- a/cli/package.json +++ b/cli/package.json @@ -26,7 +26,8 @@ "commander": "15.0.0", "esbuild": "0.28.2", "typescript": "5.2.2", - "@types/node": "22.19.0" + "@types/node": "22.19.0", + "tar": "7.5.20" }, "license": "Apache-2.0", "repository": { diff --git a/cli/scripts/build.cjs b/cli/scripts/build.cjs index 0d4d4d92..0226946f 100644 --- a/cli/scripts/build.cjs +++ b/cli/scripts/build.cjs @@ -17,6 +17,21 @@ fs.copyFileSync( path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'), path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'), ); +fs.copyFileSync( + path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'), + path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'), +); +// The operational CLI may live outside the panel and has no panel node_modules. +require('esbuild').buildSync({ + entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')], + outfile: path.join(root, 'dist/internal/maintenance/platformBundle.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + minify: true, + legalComments: 'inline', +}); require('esbuild').buildSync({ entryPoints: [path.join(root, 'src/shared/cli/commander.ts')], outfile: path.join(root, 'dist/shared/cli/commander.js'), diff --git a/cli/src/internal/maintenance/operator.ts b/cli/src/internal/maintenance/operator.ts index 55307bc4..be33e645 100644 --- a/cli/src/internal/maintenance/operator.ts +++ b/cli/src/internal/maintenance/operator.ts @@ -150,6 +150,14 @@ export async function startPanel( GRPC_PORT: context.env.QlGrpcPort || '5500', }, }; + // Archive selection and launcher ownership apply only to the upgrade worker. + // Retaining them in PM2 would pin the next online update to an offline file. + for (const name of [ + 'QL_UPGRADE_FILE', + 'QL_UPGRADE_REPAIR_DEPS', + 'QL_UPGRADE_LAUNCHER_PID', + ]) + delete options.env[name as keyof typeof options.env]; if (await executableAvailable('pm2', context.env)) { const flushed = await runProcess('pm2', ['flush'], options); if (flushed.code === 0) { diff --git a/cli/src/internal/maintenance/panelUpgrade.cjs b/cli/src/internal/maintenance/panelUpgrade.cjs index 72749b72..3b119093 100644 --- a/cli/src/internal/maintenance/panelUpgrade.cjs +++ b/cli/src/internal/maintenance/panelUpgrade.cjs @@ -6,6 +6,7 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); const artifacts = require('./upgradeArtifacts.cjs'); +const { nodeRuntimeArgs } = require('./platformBundle.cjs'); const active = new Set(['starting', 'running', 'restarting']); const stateFile = (tmp) => path.join(tmp, 'panel-upgrade.json'); @@ -200,6 +201,93 @@ async function getStatus(tmp) { return state; } +// Manual restarts accept the currently prepared package, including one retained +// after a failed preparation. Legacy installs without jobs keep their old path; +// complete-bundle installs always verify and record the actual running build. +async function withPreparedJob(tmp, env, selected, operation) { + const previous = await getStatus(tmp); + if (active.has(previous.status)) throw new Error('已有更新或重载正在执行。'); + const hasRecord = await fs + .access(stateFile(tmp)) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + if (!hasRecord) { + const pkg = env.QL_DIR + ? JSON.parse( + await fs + .readFile(path.join(env.QL_DIR, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ) + : {}; + if (pkg.qinglongUpgradeFormat !== 2) return operation(); + } + return artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + async () => { + const current = await getStatus(tmp); + if (active.has(current.status)) + throw new Error('已有更新或重载正在执行。'); + const prepared = await artifacts.selectedUpgrade( + tmp, + artifacts.releaseBranch(env), + env, + { metadataOnly: true }, + ); + if ( + prepared.source !== selected.source || + prepared.static !== selected.static + ) + throw new Error('暂存更新已变化,请重新下载更新。'); + const metadata = await stagedMetadata(selected); + const previousBuild = await readRunningBuild(env).catch(() => undefined); + const { log, error, ...record } = current; + let state = { + ...record, + ...metadata, + id: randomUUID(), + action: 'apply', + status: 'running', + phase: 'verifying', + startedAt: Date.now(), + pid: process.pid, + }; + const report = async (patch) => { + state = { ...state, ...patch, updatedAt: Date.now() }; + await save(tmp, state); + }; + await fs.writeFile( + logFile(tmp), + 'Manual application of prepared update\n', + { + mode: 0o600, + }, + ); + await report({}); + try { + const result = await operation({ + onStop: () => report({ status: 'restarting', phase: 'restarting' }), + onStart: (recover) => + recover + ? undefined + : verifyRunningVersion(env, state, previousBuild), + }); + await report({ status: 'completed', phase: 'completed' }); + return result; + } catch (error) { + await report({ status: 'failed', error: error.message }); + throw error; + } + }, + env, + ); +} + async function startJob({ root, tmp, env = process.env }, action) { if (!['download', 'apply'].includes(action)) throw new Error('Invalid upgrade action'); @@ -220,12 +308,14 @@ async function startJob({ root, tmp, env = process.env }, action) { let selected; let metadata; if (action === 'apply') { - // Fail before launching a process or stopping the working panel. + // Keep requests short on slow devices. The detached worker performs + // complete file/dependency verification before it can stop the panel. try { selected = await artifacts.selectedUpgrade( tmp, artifacts.releaseBranch(env), env, + { metadataOnly: true }, ); metadata = await stagedMetadata(selected); if (previous.status === 'ready') @@ -390,7 +480,7 @@ async function runJob({ root, tmp, env = process.env }, id) { ); await artifacts.waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); if (!recover) @@ -429,16 +519,30 @@ async function runJob({ root, tmp, env = process.env }, id) { } } -module.exports = { getStatus, startJob, runJob }; +module.exports = { + getStatus, + saveState: save, + startJob, + runJob, + withPanelLifecycle, + readRunningBuild, + verifyRunningVersion, + stagedMetadata, + withPreparedJob, +}; async function launchWorker(id) { // detached creates a session but keeps the original parent. PM2 kills the // backend's entire descendant tree, so an intermediate launcher must exit // before the worker is allowed to stop that backend. - const child = spawn(process.execPath, [__filename, 'worker', id], { - env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, - detached: true, - stdio: 'inherit', - }); + const child = spawn( + process.execPath, + [...nodeRuntimeArgs(), __filename, 'worker', id], + { + env: { ...process.env, QL_UPGRADE_LAUNCHER_PID: String(process.pid) }, + detached: true, + stdio: 'inherit', + }, + ); await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); diff --git a/cli/src/internal/maintenance/platformBundle.cjs b/cli/src/internal/maintenance/platformBundle.cjs new file mode 100644 index 00000000..4315ca34 --- /dev/null +++ b/cli/src/internal/maintenance/platformBundle.cjs @@ -0,0 +1,579 @@ +// One installed verifier handles release downloads and local offline archives. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const os = require('node:os'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); +const tar = require('tar'); +const { + prestartBackend, + dependencyFailure, + repairDependencies, +} = require('./upgradePrestart.cjs'); +const execute = promisify(execFile); +// Heavy archive parsing can hit unsupported V8 JIT paths on ARM1176. Keep +// upgrade workers interpreted without changing the installed Node or server. +const nodeRuntimeArgs = (runtime = process) => + runtime.arch === 'arm' && Number(runtime.config.variables.arm_version) === 6 + ? ['--jitless'] + : []; +const sha256 = (bytes) => + crypto.createHash('sha256').update(bytes).digest('hex'); +const inside = (root, filename) => { + const relative = path.relative(root, filename); + return ( + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +}; +const versionParts = (v) => String(v).replace(/^v/, '').split('.').map(Number); +function atLeast(actual, minimum) { + const a = versionParts(actual), + b = versionParts(minimum); + return ( + b.every((n) => Number.isFinite(n)) && + a.every((n) => Number.isFinite(n)) && + (a[0] > b[0] || + (a[0] === b[0] && + (a[1] > b[1] || (a[1] === b[1] && (a[2] || 0) >= (b[2] || 0))))) + ); +} + +async function runtimeTarget() { + let libc, libcVersion; + if (process.platform === 'linux') { + const header = process.report.getReport().header; + if (header.glibcVersionRuntime) { + libc = 'glibc'; + libcVersion = header.glibcVersionRuntime; + } else { + let text = ''; + try { + text = (await execute('ldd', ['--version'])).stdout; + } catch (error) { + text = `${error.stdout || ''}\n${error.stderr || ''}`; + } + const match = text.match(/musl[\s\S]*?Version\s+(\d+\.\d+\.\d+)/i); + if (!match) + throw new Error( + 'Cannot identify Linux libc; refusing a native dependency bundle', + ); + libc = 'musl'; + libcVersion = match[1]; + } + } + const armVersion = + process.arch === 'arm' + ? Number(process.config.variables.arm_version) + : undefined; + if (process.arch === 'arm' && ![6, 7, 8].includes(armVersion)) + throw new Error('Cannot identify ARM instruction set'); + const arch = + process.arch === 'arm' ? `armv${armVersion >= 7 ? 7 : 6}` : process.arch; + const armFloatAbi = + process.arch === 'arm' ? process.config.variables.arm_float_abi : undefined; + if ( + process.arch === 'arm' && + !['hard', 'soft', 'softfp'].includes(armFloatAbi) + ) + throw new Error('Cannot identify ARM floating-point ABI'); + return { + platform: process.platform, + arch, + libc, + libcVersion, + endianness: os.endianness(), + armFloatAbi, + }; +} + +function targetKey(target) { + return [target.platform, target.arch, target.libc].filter(Boolean).join('-'); +} + +function assertCompatible(manifest, target, node = process.versions.node) { + if ( + manifest.format !== 2 || + !/^[a-f0-9]{40}$/.test(manifest.sourceCommit) || + !/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) || + !manifest.files || + typeof manifest.files !== 'object' || + Array.isArray(manifest.files) || + !Number.isSafeInteger(manifest.nodeApi) || + manifest.nodeApi < 6 + ) + throw new Error('Invalid platform bundle manifest'); + if (targetKey(manifest.target || {}) !== targetKey(target)) + throw new Error(`Bundle platform mismatch: need ${targetKey(target)}`); + if ( + !atLeast(node, manifest.nodeMinimum) || + Number(process.versions.napi) < manifest.nodeApi + ) + throw new Error( + `Bundle requires Node >= ${manifest.nodeMinimum} and Node-API ${manifest.nodeApi}; the existing Node is retained`, + ); + if ( + manifest.target.endianness !== target.endianness || + manifest.target.armFloatAbi !== target.armFloatAbi + ) + throw new Error( + 'Bundle native byte order or ARM floating-point ABI mismatch', + ); + if (target.libc && !atLeast(target.libcVersion, manifest.target.libcMinimum)) + throw new Error( + `Bundle requires ${target.libc} >= ${manifest.target.libcMinimum}`, + ); +} + +async function inventory(roots) { + const files = {}; + async function visit(directory, prefix) { + for (const name of (await fs.readdir(directory)).sort()) { + const filename = path.join(directory, name), + relative = `${prefix}/${name}`; + const stat = await fs.lstat(filename); + if (stat.isDirectory()) await visit(filename, relative); + else if (stat.isFile()) + files[relative] = { + type: 'file', + mode: stat.mode & 0o777, + sha256: sha256(await fs.readFile(filename)), + }; + else if (stat.isSymbolicLink()) { + const link = await fs.readlink(filename); + const boundary = roots[prefix.split('/')[0]]; + if ( + path.isAbsolute(link) || + !inside(boundary, path.resolve(path.dirname(filename), link)) || + !inside(await fs.realpath(boundary), await fs.realpath(filename)) + ) + throw new Error(`Bundle link escapes its payload: ${relative}`); + files[relative] = { type: 'link', link }; + } else throw new Error(`Unsupported bundle entry: ${relative}`); + } + } + for (const [name, root] of Object.entries(roots)) await visit(root, name); + return files; +} + +async function verifyPayload(selected, manifest, target) { + assertCompatible(manifest, target); + for (const name of ['data', '.tmp', '.git', '.env', 'static']) { + if ( + await fs.lstat(path.join(selected.source, name)).catch((error) => { + if (error.code !== 'ENOENT') throw error; + return null; + }) + ) + throw new Error(`Reserved installation path in bundle: ${name}`); + } + const actual = await inventory({ + source: selected.source, + static: selected.static, + }); + const names = Object.keys(actual).sort(), + expected = Object.keys(manifest.files).sort(); + if ( + JSON.stringify(names) !== JSON.stringify(expected) || + names.some( + (name) => + JSON.stringify(actual[name]) !== JSON.stringify(manifest.files[name]), + ) + ) + throw new Error( + 'Platform bundle files or dependency checksums do not match', + ); + if ( + sha256(await fs.readFile(path.join(selected.source, 'pnpm-lock.yaml'))) !== + manifest.lockfileSha256 + ) + throw new Error('Platform bundle lockfile mismatch'); + await fs.access( + path.join(selected.source, 'node_modules/sqlite3/package.json'), + ); +} + +async function smokeDependencies(source, signal) { + // This probe is installed verifier code, never a verifier supplied by an archive. + const probe = ` + const path = require('node:path'),fs = require('node:fs'); + const root = fs.realpathSync(process.cwd()); + const r = require('node:module').createRequire(path.join(root,'package.json')); + for (const name of ['sqlite3','sequelize','cron-parser','@grpc/grpc-js','undici']) { + if (!fs.realpathSync(r.resolve(name)).startsWith(root + path.sep)) throw new Error('Dependency outside staged payload: '+name); + r(name); + } + const sqlite=r('sqlite3'), db=new sqlite.Database(':memory:'); + db.serialize(()=>{db.run('CREATE TABLE verify_bundle(value INTEGER)');db.run('INSERT INTO verify_bundle VALUES(42)'); + db.get('SELECT value FROM verify_bundle',(err,row)=>{if(err||row.value!==42){console.error(err||'SQLite readback failed');process.exitCode=1;}db.close();});}); + `; + // Match the server's normal runtime. Node 20.18's built-in HTTP parser needs + // WebAssembly, which --jitless disables; only archive workers need that flag. + await execute(process.execPath, ['-e', probe], { + cwd: source, + env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' }, + timeout: 30000, + signal, + }); +} + +async function extractArchive(archive, destination) { + const entries = new Map(), + links = new Set(); + let unpacked = 0, + invalid; + await tar.t({ + file: archive, + strict: true, + onReadEntry(entry) { + try { + const name = entry.path.replace(/\/$/, ''); + if ( + !name || + name.includes('\\') || + /[\x00-\x1f\x7f]/.test(name) || + name.split('/').some((p) => !p || p === '.' || p === '..') || + !(name === 'qinglong' || name.startsWith('qinglong/')) || + entries.has(name) || + !['File', 'OldFile', 'Directory', 'SymbolicLink'].includes(entry.type) + ) + throw new Error(`Unsafe bundle archive entry: ${name}`); + unpacked += entry.size; + if (unpacked > 2 * 1024 ** 3 || entries.size > 200000) + throw new Error('Bundle archive exceeds extraction limits'); + entries.set(name, entry.type); + if (entry.type === 'SymbolicLink') { + if ( + path.posix.isAbsolute(entry.linkpath) || + entry.linkpath.includes('\\') || + !inside( + '/qinglong', + path.posix.resolve('/', path.posix.dirname(name), entry.linkpath), + ) + ) + throw new Error('Unsafe bundle symlink'); + links.add(name); + } + } catch (error) { + invalid ||= error; + } + }, + }); + if (invalid) throw invalid; + for (const name of entries.keys()) { + let parent = path.posix.dirname(name); + while (parent !== '.') { + if (links.has(parent)) + throw new Error('Archive writes through a symlink'); + parent = path.posix.dirname(parent); + } + } + await tar.x({ + file: archive, + cwd: destination, + strict: true, + preservePaths: false, + noChown: true, + umask: 0, + }); +} + +async function stageBundle({ + root, + tmp, + env, + run, + signal, + onProgress, + verifyStatic, + download, + probe = smokeDependencies, + prestart = prestartBackend, + repair = repairDependencies, +}) { + let local = env.QL_UPGRADE_FILE; + if (!local) { + const current = JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (current.qinglongUpgradeFormat !== 2) return null; // One-time compatibility path for older releases. + } + const target = await runtimeTarget(); + const branch = ['develop', 'debian-dev'].includes(env.QL_BRANCH) + ? 'develop' + : 'master'; + const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); + const report = onProgress || (async () => {}); + try { + const archive = path.join(workspace, 'bundle.tar.gz'); + let expectedCommit; + if (local) { + if (!path.isAbsolute(local)) + throw new Error('Offline bundle path must be absolute'); + // Snapshot the local input so replacing it cannot race validation/extraction. + await fs.copyFile(local, archive); + } else { + const indexUrl = + env.QL_UPGRADE_INDEX_URL || + `https://github.com/whyour/qinglong/releases/download/upgrade-${branch}/latest.json`; + const checkUrl = (url) => { + const parsed = new URL(url); + if ( + parsed.protocol !== 'https:' && + !( + parsed.protocol === 'http:' && + ['127.0.0.1', '[::1]'].includes(parsed.hostname) + ) + ) + throw new Error('Upgrade URLs require HTTPS'); + }; + checkUrl(indexUrl); + const indexFile = path.join(workspace, 'index.json'); + await run('curl', [ + '--fail', + '--silent', + '--show-error', + '--location', + '--proto', + new URL(indexUrl).protocol === 'http:' ? '=http,https' : '=https', + '--proto-redir', + '=https', + '--header', + 'Cache-Control: no-cache', + '--connect-timeout', + '10', + '--max-time', + '30', + '--max-filesize', + '1048576', + '--output', + indexFile, + indexUrl, + ]); + const index = JSON.parse(await fs.readFile(indexFile, 'utf8')); + const entry = index.packages?.[targetKey(target)]; + if ( + index.format !== 2 || + !/^[a-f0-9]{40}$/.test(index.sourceCommit) || + !entry || + !Number.isSafeInteger(entry.size) || + entry.size <= 0 || + entry.size > 512 * 1024 ** 2 || + !/^[a-f0-9]{64}$/.test(entry.sha256) + ) + throw new Error(`No verified update bundle for ${targetKey(target)}`); + checkUrl(entry.url); + expectedCommit = index.sourceCommit; + await report({ + phase: 'downloading', + artifact: 'qinglong-bundle', + downloadedBytes: 0, + totalBytes: entry.size, + percent: 0, + }); + await download( + run, + archive, + entry.url, + 'qinglong-bundle', + report, + true, + entry, + ); + } + if ((await fs.stat(archive)).size > 512 * 1024 ** 2) + throw new Error('Bundle is too large'); + await report({ + phase: 'extracting', + artifact: 'qinglong-bundle', + percent: null, + }); + await extractArchive(archive, workspace); + const incoming = path.join(workspace, 'qinglong'); + const manifest = JSON.parse( + await fs.readFile(path.join(incoming, 'bundle.json'), 'utf8'), + ); + if (expectedCommit && manifest.sourceCommit !== expectedCommit) + throw new Error('Update index and bundle commit differ'); + const selected = { + source: path.join(workspace, `qinglong-${branch}`), + static: path.join(workspace, `qinglong-static-${branch}`), + }; + await fs.rename(path.join(incoming, 'source'), selected.source); + await fs.rename(path.join(incoming, 'static'), selected.static); + await report({ phase: 'verifying', percent: null }); + await verifyPayload(selected, manifest, target); + const build = await verifyStatic(selected.source, selected.static, env); + if (build.sourceCommit !== manifest.sourceCommit) + throw new Error('Bundle and build commit differ'); + await report({ phase: 'dependencies', percent: null }); + let dependencyFiles; + const testCandidate = async () => { + await probe(selected.source, signal); + await report({ phase: 'prestarting', percent: null }); + return prestart(selected, { target, signal }); + }; + try { + await testCandidate(); + } catch (error) { + signal?.throwIfAborted(); + if (!dependencyFailure(error)) throw error; + if (local && env.QL_UPGRADE_REPAIR_DEPS !== '1') + throw new Error( + `Offline dependency check failed; no network access attempted. Use --repair-deps to allow one repair. ${error.message}`, + ); + await report({ phase: 'repairing', percent: null }); + await repair(selected.source, { target, signal, env }); + const repaired = await inventory({ + source: selected.source, + static: selected.static, + }); + dependencyFiles = Object.fromEntries( + Object.entries(repaired).filter(([name]) => + name.startsWith('source/node_modules/'), + ), + ); + // A repair may change only dependencies, never bless changed application code. + await verifyPayload( + selected, + manifestWithDependencies(manifest, dependencyFiles), + target, + ); + await verifyStatic(selected.source, selected.static, env); + await testCandidate(); // Exactly one repair; this failure propagates. + } + await fs.writeFile( + path.join(workspace, 'prestart.json'), + JSON.stringify({ + format: 1, + sourceCommit: manifest.sourceCommit, + manifestSha256: sha256(Buffer.from(JSON.stringify(manifest))), + node: process.version, + execPath: process.execPath, + completedAt: Date.now(), + ...(dependencyFiles ? { dependencyFiles } : {}), + }), + { flag: 'wx', mode: 0o600 }, + ); + await fs.writeFile( + path.join(workspace, 'bundle.json'), + JSON.stringify(manifest), + { flag: 'wx', mode: 0o600 }, + ); + await fs.writeFile( + path.join(workspace, 'ready.json'), + JSON.stringify({ + ...selected, + root: path.resolve(root), + sourceCommit: manifest.sourceCommit, + dependenciesChanged: true, + bundle: true, + }), + { flag: 'wx', mode: 0o600 }, + ); + const pointer = path.join(workspace, 'pointer.json'); + await fs.writeFile( + pointer, + JSON.stringify({ directory: path.basename(workspace) }), + { flag: 'wx', mode: 0o600 }, + ); + signal?.throwIfAborted(); + const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`); + const previous = await fs + .readFile(readyPointer, 'utf8') + .catch(() => 'null'); + await fs.rename(pointer, readyPointer); + try { + const old = JSON.parse(previous)?.directory; + if ( + /^upgrade-[A-Za-z0-9_-]+$/.test(old) && + old !== path.basename(workspace) + ) + await fs.rm(path.join(tmp, old), { recursive: true, force: true }); + } catch { + /* Committed readiness must survive cleanup failure. */ + } + return selected; + } catch (error) { + await fs.rm(workspace, { recursive: true, force: true }); + throw error; + } +} + +function manifestWithDependencies(manifest, dependencyFiles) { + if (!dependencyFiles) return manifest; + if ( + typeof dependencyFiles !== 'object' || + Array.isArray(dependencyFiles) || + !Object.keys(dependencyFiles).length || + Object.keys(dependencyFiles).some( + (name) => !name.startsWith('source/node_modules/'), + ) + ) + throw new Error('Invalid repaired dependency inventory'); + return { + ...manifest, + files: { + ...Object.fromEntries( + Object.entries(manifest.files).filter( + ([name]) => !name.startsWith('source/node_modules/'), + ), + ), + ...dependencyFiles, + }, + }; +} + +async function verifyStagedBundle(selected) { + const manifest = JSON.parse( + await fs.readFile( + path.join(path.dirname(selected.source), 'bundle.json'), + 'utf8', + ), + ); + const prestart = JSON.parse( + await fs.readFile( + path.join(path.dirname(selected.source), 'prestart.json'), + 'utf8', + ), + ); + if ( + prestart.format !== 1 || + prestart.sourceCommit !== manifest.sourceCommit || + prestart.manifestSha256 !== sha256(Buffer.from(JSON.stringify(manifest))) || + prestart.node !== process.version || + prestart.execPath !== process.execPath || + !Number.isSafeInteger(prestart.completedAt) + ) + throw new Error( + 'Upgrade prestart receipt is missing or stale; prepare the update again', + ); + await verifyPayload( + selected, + manifestWithDependencies(manifest, prestart.dependencyFiles), + await runtimeTarget(), + ); + await smokeDependencies(selected.source); +} + +module.exports = { + nodeRuntimeArgs, + atLeast, + runtimeTarget, + targetKey, + assertCompatible, + inventory, + verifyPayload, + smokeDependencies, + extractArchive, + stageBundle, + verifyStagedBundle, + sha256, +}; diff --git a/cli/src/internal/maintenance/upgrade.ts b/cli/src/internal/maintenance/upgrade.ts index 1dbd2674..89204ed5 100644 --- a/cli/src/internal/maintenance/upgrade.ts +++ b/cli/src/internal/maintenance/upgrade.ts @@ -107,7 +107,7 @@ async function selectedUpgrade( export interface ReloadLifecycle { stop(context: LocalContext): Promise; - start(context: LocalContext): Promise; + start(context: LocalContext, recover?: boolean): Promise; } // All old entries stay on the same filesystem until service start succeeds. @@ -202,7 +202,7 @@ export async function replaceAndReload( if (item.existed) await fs.rename(item.backup, item.target); } try { - await lifecycle.start(context); + await lifecycle.start(context, true); } catch { throw new Error( translate( @@ -300,14 +300,31 @@ export async function reloadPanel( return await artifacts.withUpgradeLock( context.paths.dir_tmp!, () => - replaceAndReload(context, replacements, { - stop: stopPanel, - start: async (ctx) => { - const service = await startPanel(ctx); - await artifacts.waitForHealth(ctx.env, 60000, operationSignal()); - return service; - }, - }), + require('./panelUpgrade.cjs').withPreparedJob( + context.paths.dir_tmp!, + context.env, + selected, + (job?: { + onStop(): Promise; + onStart(recover?: boolean): Promise | undefined; + }) => + replaceAndReload(context, replacements, { + stop: async (ctx) => { + await job?.onStop(); + await stopPanel(ctx); + }, + start: async (ctx, recover) => { + const service = await startPanel(ctx); + await artifacts.waitForHealth( + ctx.env, + undefined, + operationSignal(), + ); + await job?.onStart(recover); + return service; + }, + }), + ), context.env, ); } finally { diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs index af8d0bb7..07a1e0b0 100644 --- a/cli/src/internal/maintenance/upgradeArtifacts.cjs +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -218,13 +218,19 @@ async function downloadWithProgress( '--location', ...(quiet ? ['--silent', '--show-error'] : []), '--proto', - '=https', + /^http:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d+)?\//.test(url) + ? '=http,https' + : '=https', '--proto-redir', '=https', '--connect-timeout', '30', '--max-time', - '600', + String( + artifact === 'qinglong-bundle' && expected?.size + ? Math.max(600, Math.min(7200, Math.ceil(expected.size / 8192) + 120)) + : 600, + ), '--speed-limit', '1024', '--speed-time', @@ -260,6 +266,26 @@ async function stageUpgrade({ signal, onProgress, }) { + const currentPackage = JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (env.QL_UPGRADE_FILE || currentPackage.qinglongUpgradeFormat === 2) { + return require('./platformBundle.cjs').stageBundle({ + root, + tmp, + env, + run, + signal, + onProgress, + verifyStatic: verifyUpgrade, + download: downloadWithProgress, + }); + } const report = onProgress || (async () => {}); const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const branch = releaseBranch(env); @@ -469,7 +495,12 @@ async function stageUpgrade({ } } -async function selectedUpgrade(tmp, branch, env = process.env) { +async function selectedUpgrade( + tmp, + branch, + env = process.env, + { metadataOnly = false } = {}, +) { const pointer = JSON.parse( await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), ); @@ -493,9 +524,13 @@ async function selectedUpgrade(tmp, branch, env = process.env) { ); if (ready.source !== selected.source || ready.static !== selected.static) throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); - const manifest = await verifyUpgrade(selected.source, selected.static, env); + const manifest = metadataOnly + ? await manifestAt(selected.static, env) + : await verifyUpgrade(selected.source, selected.static, env); if (ready.sourceCommit !== manifest.sourceCommit) throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); + if (ready.bundle && !metadataOnly) + await require('./platformBundle.cjs').verifyStagedBundle(selected); if (ready.dependenciesChanged) await fs.access(path.join(selected.source, 'node_modules')); else { @@ -571,7 +606,7 @@ async function replaceAndReload(replacements, lifecycle, env = process.env) { } catch (error) { // Do not modify files under a still-running failed startup. Keep backups // for recovery if stopping the new process itself fails. - if (attemptedStart) await lifecycle.stop(); + if (attemptedStart) await lifecycle.stop(true); for (const item of changed.reverse()) { await fs.rm(item.target, { recursive: true, force: true }); if (item.existed) { @@ -640,7 +675,16 @@ async function reloadSystem({ .catch(() => {}); } -async function waitForHealth(env, timeout = 60000, signal) { +function startupHealthTimeout(runtime = process) { + // ARMv6 starts the database, scheduler and HTTP workers sequentially. Give + // slow devices time to become healthy before deciding to restore a backup. + return runtime.arch === 'arm' && + Number(runtime.config.variables.arm_version) === 6 + ? 300000 + : 60000; +} + +async function waitForHealth(env, timeout = startupHealthTimeout(), signal) { const port = Number(env.QlPort || 5700); let base = env.QlBaseUrl || ''; if (base && !base.startsWith('/')) base = `/${base}`; @@ -786,9 +830,18 @@ esac mode: 0o600, }, ); + // Offline selection and worker ownership belong to this operation only. + // Passing them to PM2 would pin future online updates to the old local file. + const lifecycleEnv = { ...env }; + for (const name of [ + 'QL_UPGRADE_FILE', + 'QL_UPGRADE_REPAIR_DEPS', + 'QL_UPGRADE_LAUNCHER_PID', + ]) + delete lifecycleEnv[name]; const hook = (operation, args = [], signal) => runProcess( - env, + lifecycleEnv, 'bash', ['--', snapshot, operation, ...args], false, @@ -821,16 +874,21 @@ async function main() { tmp, () => withLifecycleHooks(root, tmp, env, async (hook) => { + let job; const lifecycle = { signal: controller.signal, - stop: () => hook('stop'), + stop: async () => { + await job?.onStop(); + await hook('stop'); + }, start: async (recover) => { await hook('start', [], recover ? undefined : controller.signal); await waitForHealth( env, - 60000, + undefined, recover ? undefined : controller.signal, ); + await job?.onStart(recover); }, }; if (process.argv[2] === 'update') { @@ -850,15 +908,48 @@ async function main() { if (process.argv[4] !== 'true') return; } else if (process.argv[2] !== 'reload') throw new Error('Unknown upgrade command.'); - await reloadSystem({ - root, - tmp, - env, - data: env.dir_data || path.join(root, 'data'), - staticRoot: env.dir_static || path.join(root, 'static'), - config: env.dir_config || path.join(root, 'data/config'), - lifecycle, - }); + const apply = async (preparedJob) => { + job = preparedJob; + await reloadSystem({ + root, + tmp, + env, + data: env.dir_data || path.join(root, 'data'), + staticRoot: env.dir_static || path.join(root, 'static'), + config: env.dir_config || path.join(root, 'data/config'), + lifecycle, + }); + }; + // Legacy reloads can carry only this self-contained Bash verifier. + // Complete-bundle installs also track manual applies without a job file. + const hasPanelJob = await fs + .access(path.join(tmp, 'panel-upgrade.json')) + .then(() => true) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return false; + }); + const packageInfo = hasPanelJob + ? {} + : JSON.parse( + await fs + .readFile(path.join(root, 'package.json'), 'utf8') + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return '{}'; + }), + ); + if (hasPanelJob || packageInfo.qinglongUpgradeFormat === 2) { + const selected = await selectedUpgrade(tmp, releaseBranch(env), env, { + metadataOnly: true, + }); + await require('./panelUpgrade.cjs').withPreparedJob( + tmp, + env, + selected, + apply, + ); + } else await apply(); console.log(upgradeMessage('更新及启动检查成功...', env)); }), env, @@ -875,6 +966,7 @@ module.exports = { selectedUpgrade, replaceAndReload, reloadSystem, + startupHealthTimeout, waitForHealth, runProcess, withLifecycleHooks, diff --git a/cli/src/internal/maintenance/upgradePrestart.cjs b/cli/src/internal/maintenance/upgradePrestart.cjs new file mode 100644 index 00000000..6a26efbe --- /dev/null +++ b/cli/src/internal/maintenance/upgradePrestart.cjs @@ -0,0 +1,393 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const crypto = require('node:crypto'); +const os = require('node:os'); + +function probeLimits(target) { + const small = target.arch === 'armv6'; + return { heapMb: small ? 128 : 192, timeoutMs: small ? 300000 : 60000 }; +} + +function dependencyFailure(error) { + const text = `${error.message || ''}\n${error.stack || ''}\n${ + error.stderr || '' + }`; + if (error.signal || /timed out|heap out of memory|ENOMEM/i.test(text)) + return false; + if ( + /ERR_DLOPEN_FAILED/.test(`${error.code} ${text}`) && + /node_modules[/\\]/.test(text) + ) + return true; + const missing = /Cannot find (?:module|package) ['"]([^'"]+)['"]/.exec( + text, + )?.[1]; + const importer = /Require stack:\r?\n-\s+([^\r\n]+)/.exec(text)?.[1]; + const inDependencies = (filename) => + /(?:^|[/\\])node_modules[/\\]/.test(filename || ''); + // Node reports a missing package entry as an absolute path and missing + // package-internal imports as relative paths. Neither is an app source error. + const dependencyMissing = + missing && + ((!missing.startsWith('.') && + !path.isAbsolute(missing) && + !missing.startsWith('node:')) || + inDependencies(missing) || + inDependencies(importer)); + // Missing backend files, bad config and database errors must never trigger npm. + return ( + (/MODULE_NOT_FOUND/.test(`${error.code} ${text}`) && + Boolean(dependencyMissing)) || + (/Could not locate the bindings file/.test(text) && + /node_modules[/\\]/.test(text)) + ); +} + +async function prestartBackend( + selected, + { target, signal, limits = probeLimits(target) }, +) { + signal?.throwIfAborted(); + const workspace = await fs.mkdtemp( + path.join(path.dirname(selected.source), 'prestart-'), + ); + try { + const data = path.join(workspace, 'data'); + for (const name of ['db', 'config', 'syslog', 'log', 'scripts', 'upload']) + await fs.mkdir(path.join(data, name), { recursive: true }); + // Copy only backend code/metadata, never all frontend assets or node_modules. + await fs.mkdir(path.join(workspace, 'static')); + await fs.cp( + path.join(selected.static, 'build'), + path.join(workspace, 'static/build'), + { recursive: true }, + ); + await fs.copyFile( + path.join(selected.static, 'build-info.json'), + path.join(workspace, 'static/build-info.json'), + ); + await fs.copyFile( + path.join(selected.source, 'version.yaml'), + path.join(workspace, 'version.yaml'), + ); + await fs.symlink( + path.join(selected.source, 'node_modules'), + path.join(workspace, 'node_modules'), + 'dir', + ); + await fs.writeFile(path.join(workspace, '.env'), '', { mode: 0o600 }); + const entry = path.join( + workspace, + 'static/build/bootstrap/upgradeProbe.js', + ); + await fs.access(entry); + const expected = JSON.parse( + await fs.readFile(path.join(selected.static, 'build-info.json')), + ); + const backendSha256 = crypto + .createHash('sha256') + .update(await fs.readFile(path.join(selected.static, 'build/app.js'))) + .digest('hex'); + const version = ( + await fs.readFile(path.join(selected.source, 'version.yaml'), 'utf8') + ).match(/^\uFEFF?version:\s*["']?(\d+\.\d+\.\d+)/m)?.[1]; + // An allowlist prevents NODE_OPTIONS, startup hooks, real data paths and + // package-manager settings from escaping into the disposable backend. + const env = { + PATH: process.env.PATH, + LANG: process.env.LANG || 'C.UTF-8', + HOME: workspace, + TMPDIR: workspace, + NODE_ENV: 'production', + QL_DIR: workspace, + QL_DATA_DIR: data, + QL_UPGRADE_PROBE: '1', + BACK_PORT: '0', + GRPC_PORT: '0', + BIND_HOST: '127.0.0.1', + BIND_HOST_GRPC: '127.0.0.1', + QlBaseUrl: '/', + NODE_PATH: '', + NODE_OPTIONS: '', + UV_THREADPOOL_SIZE: '1', + }; + return await new Promise((resolve, reject) => { + let report, + failure, + output = '', + finished = false, + killTimer; + // No PM2/cluster workers. OpenSSL key generation is sequential in this + // one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support. + const child = spawn( + process.execPath, + [`--max-old-space-size=${limits.heapMb}`, entry], + { + cwd: workspace, + env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }, + ); + try { + if (child.pid) os.setPriority(child.pid, 10); + } catch {} + const kill = (signalName) => { + try { + if (process.platform !== 'win32' && child.pid) + process.kill(-child.pid, signalName); + else child.kill(signalName); + } catch (error) { + if (error.code !== 'ESRCH') failure ||= error; + } + }; + const stop = (error) => { + failure ||= error; + kill('SIGTERM'); + killTimer ||= setTimeout(() => kill('SIGKILL'), 1500); + }; + const abort = () => + stop(signal.reason || new Error('Upgrade prestart aborted')); + const timer = setTimeout( + () => stop(new Error('Upgrade prestart timed out')), + limits.timeoutMs, + ); + child.stdout.on('data', (chunk) => { + output = (output + chunk).slice(-32768); + }); + child.stderr.on('data', (chunk) => { + output = (output + chunk).slice(-32768); + }); + child.on('message', (message) => { + if (message?.type === 'upgrade-probe') report = message; + }); + const finish = (error) => { + if (finished) return; + finished = true; + clearTimeout(timer); + clearTimeout(killTimer); + signal?.removeEventListener('abort', abort); + if (error) reject(error); + else resolve(report); + }; + child.on('error', (error) => finish(error)); + // Descendants can retain our output pipes after their parent exits. + // Reap the group on exit rather than waiting for those pipes to close. + child.once('exit', () => kill('SIGKILL')); + child.on('close', (code, exitSignal) => { + // Reap descendants even when the backend exits unexpectedly. + kill('SIGKILL'); + if (failure) return finish(failure); + if (code !== 0 || !report?.ok) + return finish( + Object.assign( + new Error( + report?.message || + `Upgrade prestart exited: ${exitSignal || code}\n${output}`, + ), + { + code: report?.code, + stack: report?.stack || output, + signal: exitSignal, + }, + ), + ); + if ( + report.build?.sourceCommit !== expected.sourceCommit || + report.build?.backendSha256 !== backendSha256 || + report.build?.version !== version + ) + return finish(new Error('Upgrade prestart build identity mismatch')); + finish(); + }); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + }); + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} + +async function repairDependencies(source, { target, signal, env }) { + const workspace = await fs.mkdtemp( + path.join(path.dirname(source), 'repair-'), + ); + try { + const pkg = JSON.parse( + await fs.readFile(path.join(source, 'package.json')), + ); + if (pkg.packageManager !== 'pnpm@8.3.1') + throw new Error('Unsupported frozen-lockfile package manager'); + // Install apart from the verified application: lifecycle scripts cannot + // rewrite source/static files or use the old installation's node_modules. + await fs.writeFile( + path.join(workspace, 'package.json'), + JSON.stringify({ ...pkg, scripts: {} }), + ); + await fs.copyFile( + path.join(source, 'pnpm-lock.yaml'), + path.join(workspace, 'pnpm-lock.yaml'), + ); + const small = target.arch === 'armv6'; + const childEnv = { + PATH: env.PATH || process.env.PATH, + HOME: workspace, + TMPDIR: workspace, + NODE_OPTIONS: `${small ? '--jitless ' : ''}--max-old-space-size=${ + small ? 256 : 384 + }`, + NODE_PATH: '', + CI: 'true', + npm_config_jobs: '1', + MAKEFLAGS: '-j1', + ...(small + ? { + npm_config_build_from_source: 'true', + npm_config_force_process_config: 'true', + CFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard', + CXXFLAGS: '-march=armv6 -mfpu=vfp -mfloat-abi=hard', + } + : {}), + }; + // Preserve network routing without inheriting Node/preload/compiler settings. + for (const name of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'all_proxy', + 'no_proxy', + ]) + if (typeof env[name] === 'string') childEnv[name] = env[name]; + // Reuse matching installed headers on small devices; never inherit a + // caller's nodedir or accidentally compile against a different Node ABI. + const prefix = path.resolve(path.dirname(process.execPath), '..'); + const header = await fs + .readFile(path.join(prefix, 'include/node/node_version.h'), 'utf8') + .catch(() => ''); + const headerVersion = ['MAJOR', 'MINOR', 'PATCH'] + .map( + (part) => + header.match( + new RegExp(`#define\\s+NODE_${part}_VERSION\\s+(\\d+)`), + )?.[1], + ) + .join('.'); + if (headerVersion === process.versions.node) { + childEnv.npm_config_nodedir = prefix; + childEnv.npm_config_force_process_config = 'true'; + } + const run = (args) => + runRepair('pnpm', args, { + cwd: workspace, + env: childEnv, + signal, + timeoutMs: small ? 1800000 : 600000, + }); + const version = await run(['--version']); + if (version.trim() !== '8.3.1') + throw new Error('Dependency repair requires existing pnpm 8.3.1'); + await run([ + 'install', + '--prod', + '--frozen-lockfile', + '--ignore-scripts', + '--child-concurrency=1', + '--network-concurrency=2', + ]); + await run(['rebuild', 'sqlite3', '@whyour/sqlite3']); + if ( + !(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))).equals( + await fs.readFile(path.join(workspace, 'pnpm-lock.yaml')), + ) + ) + throw new Error('Dependency repair changed the frozen lockfile'); + await fs.rm(path.join(source, 'node_modules'), { + recursive: true, + force: true, + }); + await fs.rename( + path.join(workspace, 'node_modules'), + path.join(source, 'node_modules'), + ); + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} + +// Bound output, wall time and the entire npm/node-gyp process group. +async function runRepair(program, args, options) { + return new Promise((resolve, reject) => { + options.signal?.throwIfAborted(); + let output = '', + stdout = '', + failure, + killTimer; + const child = spawn(program, args, { + cwd: options.cwd, + env: options.env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + const kill = (sig) => { + try { + if (child.pid && process.platform !== 'win32') + process.kill(-child.pid, sig); + else child.kill(sig); + } catch {} + }; + const stop = (error) => { + failure ||= error; + kill('SIGTERM'); + killTimer ||= setTimeout(() => kill('SIGKILL'), 1500); + }; + const abort = () => + stop(options.signal.reason || new Error('Dependency repair aborted')); + const timer = setTimeout( + () => stop(new Error('Dependency repair timed out')), + options.timeoutMs, + ); + const capture = (chunk) => { + output = (output + chunk).slice(-32768); + }; + child.stdout.on('data', (chunk) => { + stdout = (stdout + chunk).slice(-32768); + capture(chunk); + }); + child.stderr.on('data', capture); + const cleanup = () => { + clearTimeout(timer); + clearTimeout(killTimer); + options.signal?.removeEventListener('abort', abort); + kill('SIGKILL'); + }; + child.on('error', (error) => { + cleanup(); + reject(error); + }); + child.once('exit', () => kill('SIGKILL')); + child.on('close', (code, signal) => { + cleanup(); + if (failure || code !== 0) + reject( + failure || + new Error( + `Dependency repair failed (${signal || code}): ${output}`, + ), + ); + else resolve(stdout); + }); + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + }); +} + +module.exports = { + prestartBackend, + dependencyFailure, + repairDependencies, + probeLimits, +}; diff --git a/cli/test/internal/local.test.cjs b/cli/test/internal/local.test.cjs index 4d4438dc..047a716e 100644 --- a/cli/test/internal/local.test.cjs +++ b/cli/test/internal/local.test.cjs @@ -322,6 +322,10 @@ test( async (t) => { if (process.platform === 'win32') return t.skip('POSIX process groups'); const { runProcess } = require('../../dist/internal/runtime/process'); + const marker = path.join(sandbox(t), 'timeout'); + const controller = new AbortController(); + const guard = setTimeout(() => controller.abort(), 4000); + t.after(() => clearTimeout(guard)); let descendant; t.after(() => { if (descendant) @@ -329,13 +333,17 @@ test( process.kill(descendant, 'SIGKILL'); } catch {} }); + // Start the deadline only after the descendant installed its handlers. + // Cold Node startup under parallel load need not fit in 350 milliseconds. const grandchild = - 'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);setInterval(()=>{},1000)'; + 'process.on("SIGINT",()=>{});process.on("SIGTERM",()=>{});console.log(process.pid);require("node:fs").writeSync(3,"T");setInterval(()=>{},1000)'; const parent = `require('node:child_process').spawn(process.execPath,['-e',${JSON.stringify( grandchild, - )}],{stdio:['ignore',1,2]});setTimeout(()=>process.exit(0),100);`; + )}],{stdio:['ignore',1,2,3]});setTimeout(()=>process.exit(0),100);`; const result = await runProcess(process.execPath, ['-e', parent], { timeoutMs: 350, + timeoutControl: { marker }, + signal: controller.signal, graceMs: 100, output: (chunk) => { const pid = Number(chunk.toString().trim()); @@ -345,6 +353,7 @@ test( assert.equal(result.code, 124); assert.equal(result.timedOut, true); assert.ok(descendant); + assert.equal(fs.existsSync(marker), true); }, ); diff --git a/cli/test/internal/operator.test.cjs b/cli/test/internal/operator.test.cjs index 91c742fa..165a81e9 100644 --- a/cli/test/internal/operator.test.cjs +++ b/cli/test/internal/operator.test.cjs @@ -20,6 +20,40 @@ const { sourceCommit, } = require('../../../test/helpers/upgrade-fixture.cjs'); +test('CLI service startup drops operation-only upgrade variables and preserves user configuration', async (t) => { + const ctx = await fixture(t); + ctx.env.CALLS = path.join(ctx.root, 'runtime.jsonl'); + ctx.env.QL_UPGRADE_FILE = '/old/offline.tar.gz'; + ctx.env.QL_UPGRADE_REPAIR_DEPS = '1'; + ctx.env.QL_UPGRADE_LAUNCHER_PID = '12345'; + ctx.env.QA_USER_SETTING = 'preserved'; + ctx.env.HTTPS_PROXY = 'http://127.0.0.1:7890'; + await stub( + ctx, + 'pm2', + `require('node:fs').appendFileSync(process.env.CALLS,JSON.stringify({ + file:process.env.QL_UPGRADE_FILE??null,repair:process.env.QL_UPGRADE_REPAIR_DEPS??null, + launcher:process.env.QL_UPGRADE_LAUNCHER_PID??null,user:process.env.QA_USER_SETTING, + proxy:process.env.HTTPS_PROXY, + })+'\\n');`, + ); + await startPanel(ctx); + for (const runtime of (await fs.readFile(ctx.env.CALLS, 'utf8')) + .trim() + .split('\n') + .map(JSON.parse)) + assert.deepEqual(runtime, { + file: null, + repair: null, + launcher: null, + user: 'preserved', + proxy: 'http://127.0.0.1:7890', + }); + assert.equal(ctx.env.QL_UPGRADE_FILE, '/old/offline.tar.gz'); + assert.equal(ctx.env.QL_UPGRADE_REPAIR_DEPS, '1'); + assert.equal(ctx.env.QL_UPGRADE_LAUNCHER_PID, '12345'); +}); + async function fixture(t) { t.mock.method( require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), diff --git a/cli/test/internal/platformBundle.test.cjs b/cli/test/internal/platformBundle.test.cjs new file mode 100644 index 00000000..3ce4a7de --- /dev/null +++ b/cli/test/internal/platformBundle.test.cjs @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +test('the standalone operational CLI verifier carries its archive parser without panel dependencies', (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-cli-bundle-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const filename = path.join(directory, 'platformBundle.cjs'); + fs.copyFileSync( + path.resolve( + __dirname, + '../../dist/internal/maintenance/platformBundle.cjs', + ), + filename, + ); + const code = ` + const Module=require('node:module'),load=Module._load; + Module._load=function(name,...args){ + if(name!==process.argv[1]&&!name.startsWith('node:')&&!Module.builtinModules.includes(name))throw new Error('External dependency: '+name); + return load.call(this,name,...args); + }; + const bundle=require(process.argv[1]); + if(typeof bundle.stageBundle!=='function')throw new Error('Missing verifier'); + bundle.runtimeTarget().then(target=>console.log(bundle.targetKey(target))).catch(e=>{console.error(e);process.exitCode=1}); + `; + const result = spawnSync(process.execPath, ['-e', code, filename], { + encoding: 'utf8', + env: { ...process.env, NODE_PATH: '' }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(`^${process.platform}-`)); +}); diff --git a/cli/test/internal/preparedUpgrade.test.cjs b/cli/test/internal/preparedUpgrade.test.cjs new file mode 100644 index 00000000..02d3f244 --- /dev/null +++ b/cli/test/internal/preparedUpgrade.test.cjs @@ -0,0 +1,99 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { + manualUpgradeFixture, +} = require('../../../test/helpers/manual-upgrade-fixture.cjs'); +const { createContext } = require('../../dist/internal/runtime/context'); +const { reloadPanel } = require('../../dist/internal/maintenance/upgrade'); +const operator = require('../../dist/internal/maintenance/operator'); +const artifacts = require('../../dist/internal/maintenance/upgradeArtifacts.cjs'); + +for (const status of ['ready', 'failed', 'completed', 'absent']) + for (const wrongVersion of [false, true]) + test(`CLI validates and records manual reload after ${status}; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + await fs.writeFile( + path.join(f.root, 'package.json'), + '{"qinglongUpgradeFormat":2}', + ); + const file = path.join(f.tmp, 'panel-upgrade.json'); + if (status === 'absent') await fs.unlink(file); + else + await fs.writeFile( + file, + JSON.stringify({ + ...(await f.state()), + status, + error: 'old error', + version: '9.9.9', + sourceCommit: 'f'.repeat(40), + }), + ); + t.mock.method(operator, 'stopPanel', async () => {}); + t.mock.method(operator, 'startPanel', async () => ({ + manager: 'fixture', + })); + t.mock.method(artifacts, 'waitForHealth', async () => {}); + const operation = reloadPanel( + createContext({ root: f.root }, f.env), + 'system', + ); + if (wrongVersion) + await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + const state = await f.state(); + assert.equal(state.status, wrongVersion ? 'failed' : 'completed'); + assert.equal(state.sourceCommit, f.expected.sourceCommit); + assert.equal(state.version, f.expected.version); + assert.notEqual(state.error, 'old error'); + assert.equal( + await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'), + wrongVersion ? 'old backend' : 'new backend', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'), + 'preserve data', + ); + }); + +for (const wrongVersion of [false, true]) { + test(`operational CLI synchronizes prepared jobs and verifies startup before committing; wrong version=${wrongVersion}`, async (t) => { + const f = await manualUpgradeFixture(t, wrongVersion); + const context = createContext({ root: f.root }, f.env); + const timeouts = []; + t.mock.method(artifacts, 'waitForHealth', async (_env, timeout) => { + timeouts.push(timeout); + }); + t.mock.method(operator, 'stopPanel', async () => { + assert.equal((await f.state()).status, 'restarting'); + }); + t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' })); + const operation = reloadPanel(context, 'system'); + if (wrongVersion) await assert.rejects(operation, /启动版本与更新包不一致/); + else await operation; + assert.ok(timeouts.length > 0); + assert.ok( + timeouts.every((timeout) => timeout === undefined), + 'use the shared ARMv6/default startup deadline', + ); + assert.equal( + (await f.state()).status, + wrongVersion ? 'failed' : 'completed', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'static/build/app.js'), 'utf8'), + wrongVersion ? 'old backend' : 'new backend', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'data/keep'), 'utf8'), + 'preserve data', + ); + if (wrongVersion) + assert.equal( + await fs.readFile(path.join(f.root, 'node_modules/old'), 'utf8'), + 'old dependency', + ); + }); +} diff --git a/docker/Dockerfile.armv6-glibc b/docker/Dockerfile.armv6-glibc new file mode 100644 index 00000000..e80c731f --- /dev/null +++ b/docker/Dockerfile.armv6-glibc @@ -0,0 +1,46 @@ +# ARMv7 is a source of application files only. None of its system libraries or +# Node binaries are executed or copied into the ARMv6 runtime. +ARG SOURCE_IMAGE +FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source + +# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers. +FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime +COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y --no-install-recommends \ + debootstrap gpg gpgv ca-certificates curl qemu-user-static && \ + rm -rf /var/lib/apt/lists/* +# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest +# access checks. Pin the upstream static tracer instead of misresolving paths. +RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \ + echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \ + chmod 755 /usr/local/bin/proot && proot --version +RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \ + echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \ + gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \ + debootstrap --foreign --arch=armhf --variant=minbase \ + --keyring=/usr/share/keyrings/raspbian.gpg \ + bullseye /armv6 https://archive.raspbian.org/raspbian +COPY docker/armv6-run /usr/local/bin/armv6-run +RUN chmod 755 /usr/local/bin/armv6-run && \ + DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \ + cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \ + printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \ + mkdir -p /armv6/etc/ssl/certs && \ + cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \ + printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \ + printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \ + chmod 755 /armv6/usr/sbin/policy-rc.d && \ + armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*' +# This Node is a pinned CI/test prerequisite, never part of the upgrade archive. +# Build against the minimum supported Node, including its bundled C++ headers. +RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \ + echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \ + tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz +COPY --from=source /ql/ /armv6/ql/ +# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries. +COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/ +RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \ + armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\''' +LABEL qinglong.qa.runtime="armv6-glibc-arm1176" +ENTRYPOINT ["/usr/local/bin/armv6-run"] diff --git a/docker/Dockerfile.upgrade-bundle b/docker/Dockerfile.upgrade-bundle new file mode 100644 index 00000000..1e4b074b --- /dev/null +++ b/docker/Dockerfile.upgrade-bundle @@ -0,0 +1,30 @@ +# The image digest is supplied by the same workflow run, never a rolling tag. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} AS bundle +ARG BASE_IMAGE +ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE} +ARG TARGETARCH +ARG TARGETVARIANT +USER root +# The generic "arm" sqlite release is ARMv7. ARMv6 must compile its own addon. +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ + apk add --no-cache --virtual .ql-bundle-build build-base binutils python3 nodejs-dev && \ + cd "$(readlink -f /ql/node_modules/sqlite3)" && \ + CFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \ + CXXFLAGS='-march=armv6 -mfpu=vfp -mfloat-abi=hard' \ + npm_config_nodedir=/usr npm_config_build_from_source=true QEMU_CPU=arm1176 \ + node -e 'require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})' && \ + readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E 'Tag_CPU_arch: v6($|K)' && \ + rm -rf build && \ + apk del .ql-bundle-build; \ + fi +RUN ulimit -c 0; if [ "$TARGETARCH/$TARGETVARIANT" = arm/v6 ]; then \ + export QEMU_CPU=arm1176; \ + node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out && \ + for archive in /out/*.tar.gz; do \ + node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install && \ + rm -rf /tmp/ql-bundle-offline-smoke || exit 1; \ + done; \ + else node /ql/scripts/build-upgrade-bundle.cjs /ql /out; fi +FROM scratch +COPY --from=bundle /out/ / diff --git a/docker/Dockerfile.upgrade-bundle-armv6-glibc b/docker/Dockerfile.upgrade-bundle-armv6-glibc new file mode 100644 index 00000000..0c073ac3 --- /dev/null +++ b/docker/Dockerfile.upgrade-bundle-armv6-glibc @@ -0,0 +1,13 @@ +# An immutable digest of the CI-only ARM1176 validator, built in the same run. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} AS bundle +ARG BASE_IMAGE +ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE} +RUN ulimit -c 0; armv6-run /bin/bash -ec \ + 'node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out; \ + for archive in /out/*.tar.gz; do \ + node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install; \ + rm -rf /tmp/ql-bundle-offline-smoke; \ + done' +FROM scratch +COPY --from=bundle /armv6/out/ / diff --git a/docker/armv6-run b/docker/armv6-run new file mode 100755 index 00000000..4d6a2ff7 --- /dev/null +++ b/docker/armv6-run @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176 +# Bind only kernel interfaces; all application files and writable state remain +# inside this container's ARMv6 root. Child processes also pass through QEMU. +exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \ + -r /armv6 -b /proc -b /dev -w / "$@" diff --git a/docs/development/platform-upgrade-bundles.md b/docs/development/platform-upgrade-bundles.md new file mode 100644 index 00000000..454cba57 --- /dev/null +++ b/docs/development/platform-upgrade-bundles.md @@ -0,0 +1,77 @@ +# 平台完整升级包 + +CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中的运行脚本和模板、前后端编译产物、锁文件和已经安装的生产 `node_modules`。不包含 Node 可执行文件、用户数据、`.env`、Git 历史或 npm 缓存。保留现有 Node 和用户脚本依赖。 + +## 在线升级 + +`package.json` 中 `qinglongUpgradeFormat: 2` 表示使用完整包。旧版升级器仍可通过原归档路径完成首次迁移;完整包版本不会在平台包缺失、损坏或不兼容时退回设备端 npm 安装。仅当完整包已通过校验、依赖加载或后端预启动明确报告依赖错误时,在线升级会按同一冻结锁文件修复一次生产依赖,然后再次预启动;第二次失败即停止,保留正在运行的旧服务。 + +面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。 + +正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行: + +```sh +gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true +``` + +手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v--`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。 + +开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。 + +版本入口为 GitHub prerelease `upgrade-v` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v--` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。 + +## 手动离线安装和升级 + +从 `upgrade-v` 的说明或 `latest.json` 中取得当前批次的下载链接,下载匹配设备的 `.tar.gz` 和独立 `install-bundle.cjs`;两者都来自同一个 `upgrade-build-v--` release。独立安装器已内置解析依赖,在目标设备正常情况无需 npm、pnpm 或重新安装 SQLite。离线准备和预启动默认不联网;依赖不符合要求时直接报错。可对照同批次 release 的 `latest.json` 核对归档 SHA256。 + +已有安装(默认应用并重启): + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/qinglong--linux-arm64-musl.tar.gz +``` + +只准备,稍后手动重启: + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only +ql reload system +``` + +仅在明确允许联网修复时添加 `--repair-deps`(安装和升级均支持),例如: + +```sh +node install-bundle.cjs --root /ql --file /absolute/path/package.tar.gz --download-only --repair-deps +``` + +修复需要已有 pnpm 8.3.1 及必要编译工具。依赖在私有目录按 `--prod --frozen-lockfile` 重装,仅重建 SQLite;ARMv6 强制源码编译和 ARMv6 硬浮点指令,禁止下载 generic ARM 的 ARMv7 二进制。修复仅执行一次;不安装或替换 Node、不改锁文件、不改已校验源码和静态产物。应用前以原包校验清单和独立记录的修复依赖摘要复验。 + +修复继承调用环境中的标准 HTTP/HTTPS/ALL/NO_PROXY(含小写形式),其余 Node 预加载和编译参数仍隔离。修复安装或 SQLite 重建失败时,清理私有目录,保留当前服务、当前依赖及上一次就绪包;不会标记本次更新就绪。预启动或修复主进程退出时立即终止其进程组,防止持有输出管道的子进程继续写入已清理目录。 + +通过 Bash 或已选择的本地 CLI 执行上述重启时,也会把已准备的面板任务更新为完成或失败;启动成功必须同时匹配目标版本、提交和后端哈希,匹配失败则恢复旧文件和服务。 + +手动应用会以当前就绪指针为准建立新的应用任务,并清除之前的失败信息。因此,准备失败后应用保留的旧就绪包、此前任务已完成后再次应用,以及完整包安装中任务记录丢失或尚未建立时,仍会校验实际启动版本并同步面板状态。未支持完整包且没有任务记录的旧版保留原重载兼容路径。 + +重启中断后的停止新服务、恢复文件和启动旧服务不再使用已经取消的信号。若新服务无法停止,则保留备份,避免在仍运行的进程下覆盖文件;若文件已恢复但旧服务也无法启动,任务仍报告失败。Bash 和本地 CLI 启动后端时都移除离线包选择、修复许可和升级启动器 PID,避免后续在线更新误用旧离线包;用户配置及代理保持不变。回滚恢复应用文件和依赖,不把用户数据库恢复到历史快照;数据库迁移仍需保持向后兼容。 + +安装到空目录: + +```sh +node install-bundle.cjs --root /opt/qinglong --file /absolute/path/package.tar.gz --install +QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/start-simplify.sh +``` + +`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。 + +ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。 + +## 平台兼容 + +- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。 +- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。 +- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。 +- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。 +- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。 +- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。 +- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。 + +自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。 diff --git a/package.json b/package.json index e36e566e..bb03612f 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,8 @@ "undici": "^7.9.0", "uuid": "^11.0.3", "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0" + "winston-daily-rotate-file": "^5.0.0", + "tar": "7.5.20" }, "devDependencies": { "@ant-design/icons": "^5.0.1", @@ -201,5 +202,6 @@ "typescript": "5.2.2", "vh-check": "^2.0.5", "virtualizedtableforantd4": "1.3.0" - } + }, + "qinglongUpgradeFormat": 2 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 863bffc4..38aac98d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ dependencies: sqlite3: specifier: npm:@whyour/sqlite3@1.1.2 version: /@whyour/sqlite3@1.1.2 + tar: + specifier: 7.5.20 + version: 7.5.20 toad-scheduler: specifier: ^3.0.1 version: 3.0.1 @@ -3053,7 +3056,6 @@ packages: dependencies: minipass: 7.1.3 dev: false - optional: true /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} @@ -6623,7 +6625,6 @@ packages: engines: {node: '>=18'} requiresBuild: true dev: false - optional: true /ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} @@ -10701,7 +10702,6 @@ packages: dependencies: minipass: 7.1.3 dev: false - optional: true /mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} @@ -14706,7 +14706,6 @@ packages: minizlib: 3.1.0 yallist: 5.0.0 dev: false - optional: true /terser@5.36.0: resolution: {integrity: sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==} @@ -15727,7 +15726,6 @@ packages: engines: {node: '>=18'} requiresBuild: true dev: false - optional: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} diff --git a/scripts/build-bundle-installer.cjs b/scripts/build-bundle-installer.cjs new file mode 100644 index 00000000..34784fee --- /dev/null +++ b/scripts/build-bundle-installer.cjs @@ -0,0 +1,22 @@ +const path = require('node:path'); +require('../cli/node_modules/esbuild').buildSync({ + entryPoints: [path.resolve(__dirname, '../shell/install-bundle.cjs')], + outfile: path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + minify: true, + legalComments: 'inline', +}); +const { spawnSync } = require('node:child_process'); +const result = spawnSync( + process.execPath, + [path.resolve(process.argv[2] || 'output/upgrade/install-bundle.cjs')], + { encoding: 'utf8' }, +); +if ( + result.status !== 1 || + !result.stderr.includes('Usage: node install-bundle.cjs') +) + throw new Error('Standalone offline installer did not start correctly'); diff --git a/scripts/build-upgrade-bundle.cjs b/scripts/build-upgrade-bundle.cjs new file mode 100644 index 00000000..27bac8e3 --- /dev/null +++ b/scripts/build-upgrade-bundle.cjs @@ -0,0 +1,142 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tar = require('tar'); +const bundle = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const { + verifyUpgrade, +} = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); + +async function normalizeModes(directory) { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await fs.chmod(filename, 0o755); + await normalizeModes(filename); + } else if (entry.isFile()) { + const stat = await fs.stat(filename); + await fs.chmod(filename, 0o644 | (stat.mode & 0o111)); + } + } +} + +async function buildBundle(root, output) { + root = path.resolve(root); + output = path.resolve(output); + const target = await bundle.runtimeTarget(); + if (process.platform !== 'linux') + throw new Error('Release bundles must be built and probed on Linux'); + const build = await verifyUpgrade(root, path.join(root, 'static')); + await bundle.smokeDependencies(root); + await fs.mkdir(output, { recursive: true }); + const workspace = await fs.mkdtemp(path.join(output, '.bundle-')); + try { + const payload = path.join(workspace, 'qinglong'); + const source = path.join(payload, 'source'), + staticRoot = path.join(payload, 'static'); + await fs.mkdir(source, { recursive: true }); + const excluded = new Set([ + '.git', + '.tmp', + '.env', + 'data', + 'static', + 'output', + 'node_modules', + ]); + for (const name of await fs.readdir(root)) { + if (excluded.has(name)) continue; + await fs.cp(path.join(root, name), path.join(source, name), { + recursive: true, + verbatimSymlinks: true, + }); + } + await fs.cp( + path.join(root, 'node_modules'), + path.join(source, 'node_modules'), + { recursive: true, verbatimSymlinks: true }, + ); + await fs.cp(path.join(root, 'static'), staticRoot, { + recursive: true, + verbatimSymlinks: true, + }); + await normalizeModes(source); + await normalizeModes(staticRoot); + const manifest = { + format: 2, + sourceCommit: build.sourceCommit, + version: JSON.parse( + await fs.readFile(path.join(root, 'package.json'), 'utf8'), + ).version, + lockfileSha256: build.lockfileSha256, + target: { + platform: target.platform, + arch: target.arch, + libc: target.libc, + libcMinimum: target.libcVersion, + endianness: target.endianness, + armFloatAbi: target.armFloatAbi, + }, + nodeMinimum: '20.18.1', + nodeApi: 6, + files: await bundle.inventory({ source, static: staticRoot }), + }; + await fs.writeFile( + path.join(payload, 'bundle.json'), + JSON.stringify(manifest), + ); + console.log('Bundle inventory verified; writing archive'); + const filename = `qinglong-${build.sourceCommit}-${bundle.targetKey( + target, + )}.tar.gz`; + await tar.c( + { + gzip: true, + portable: true, + noMtime: true, + cwd: workspace, + file: path.join(output, filename), + }, + ['qinglong'], + ); + // Verify the bytes users receive, not just the pre-archive build directory. + const roundtrip = path.join(workspace, 'roundtrip'); + await fs.mkdir(roundtrip); + console.log('Verifying archived paths, permissions and checksums'); + await bundle.extractArchive(path.join(output, filename), roundtrip); + const extracted = { + source: path.join(roundtrip, 'qinglong/source'), + static: path.join(roundtrip, 'qinglong/static'), + }; + await bundle.verifyPayload(extracted, manifest, target); + await verifyUpgrade(extracted.source, extracted.static); + await bundle.smokeDependencies(extracted.source); + console.log('Archived production dependencies and SQLite verified'); + const bytes = await fs.readFile(path.join(output, filename)); + const record = { + format: 2, + sourceCommit: build.sourceCommit, + version: manifest.version, + target: bundle.targetKey(target), + filename, + baseImage: process.env.QL_BUNDLE_BASE_IMAGE, + size: bytes.length, + sha256: bundle.sha256(bytes), + }; + await fs.writeFile( + path.join(output, filename + '.json'), + JSON.stringify(record) + '\n', + ); + console.log(JSON.stringify(record)); + return record; + } finally { + await fs.rm(workspace, { recursive: true, force: true }); + } +} +module.exports = { buildBundle, normalizeModes }; +if (require.main === module) + buildBundle(process.argv[2] || '/ql', process.argv[3] || '/out').catch( + (e) => { + console.error(e); + process.exitCode = 1; + }, + ); diff --git a/scripts/publish-upgrade-bundles.cjs b/scripts/publish-upgrade-bundles.cjs new file mode 100644 index 00000000..7fc9b715 --- /dev/null +++ b/scripts/publish-upgrade-bundles.cjs @@ -0,0 +1,253 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { + sha256, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); +const platforms = [ + 'linux-x64-musl', + 'linux-armv6-musl', + 'linux-armv7-musl', + 'linux-arm64-musl', + 'linux-ppc64-musl', + 'linux-s390x-musl', + 'linux-ia32-musl', + 'linux-x64-glibc', + 'linux-armv6-glibc', + 'linux-armv7-glibc', + 'linux-arm64-glibc', + 'linux-ppc64-glibc', + 'linux-s390x-glibc', +]; +function releaseIndex(directory, commit, repository, options = {}) { + const { version, releaseTag = `upgrade-${commit}` } = options; + if ( + !/^[a-f0-9]{40}$/.test(commit) || + !/^[\w.-]+\/[\w.-]+$/.test(repository) || + !/^[\w.+-]+$/.test(releaseTag) + ) + throw new Error('Invalid release source'); + const packages = {}; + for (const target of platforms) { + const filename = `qinglong-${commit}-${target}.tar.gz`; + const record = JSON.parse( + fs.readFileSync(path.join(directory, filename + '.json')), + ); + const bytes = fs.readFileSync(path.join(directory, filename)); + if ( + record.format !== 2 || + record.sourceCommit !== commit || + (version !== undefined && record.version !== version) || + record.target !== target || + record.filename !== filename || + record.size !== bytes.length || + record.sha256 !== sha256(bytes) + ) + throw new Error(`Unverified platform bundle: ${target}`); + packages[target] = { + size: record.size, + sha256: record.sha256, + url: `https://github.com/${repository}/releases/download/${encodeURIComponent( + releaseTag, + )}/${filename}`, + }; + } + return { format: 2, sourceCommit: commit, packages }; +} +function publishBundles({ + directory, + commit, + repository, + branch, + version, + buildId, + allowDevelopment = false, + gh = (...args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), +}) { + if ( + branch !== 'master' && + !(branch === 'develop' && allowDevelopment === true) + ) + throw new Error( + 'Only master or explicitly requested development images publish bundles', + ); + if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || '')) + throw new Error('Invalid bundle version'); + if (!/^\d+-\d+$/.test(buildId || '')) + throw new Error('Invalid bundle build ID'); + // Rebuilding a version gets new download URLs, including when its commit is + // unchanged. Only the version/channel pointer is overwritten, never bytes + // that an in-flight download has already selected by hash. + const development = branch === 'develop'; + const tag = `upgrade-build-${ + development ? 'develop-' : '' + }v${version}-${buildId}`; + const index = { + ...releaseIndex(directory, commit, repository, { + version, + releaseTag: tag, + }), + version, + buildId, + channel: branch, + }; + const installer = fs.readFileSync(path.join(directory, 'install-bundle.cjs')); + index.installer = { + size: installer.length, + sha256: sha256(installer), + url: `https://github.com/${repository}/releases/download/${encodeURIComponent( + tag, + )}/install-bundle.cjs`, + }; + fs.writeFileSync( + path.join(directory, 'latest.json'), + JSON.stringify(index) + '\n', + ); + const lookup = (ref) => { + try { + return JSON.parse( + gh( + 'api', + `repos/${repository}/releases/tags/${encodeURIComponent(ref)}`, + ), + ); + } catch (error) { + if (/\bHTTP 404\b/.test(String(error.stderr))) return null; + throw error; + } + }; + const notes = + `Verified platform bundles for v${version}, source ${commit}, build ${buildId}. Existing Node runtime is retained.\n\n` + + Object.entries(index.packages) + .map(([target, entry]) => `- [${target}](${entry.url})`) + .join('\n') + + `\n- [Standalone offline installer](${index.installer.url})`; + const ensureRelease = (ref, title) => { + if (!lookup(ref)) + gh( + 'release', + 'create', + ref, + '--repo', + repository, + '--target', + commit, + '--prerelease', + '--latest=false', + '--title', + title, + '--notes', + notes, + ); + }; + const currentSource = () => + gh( + 'api', + `repos/${repository}/git/ref/heads/${branch}`, + '--jq', + '.object.sha', + ).trim() === commit; + if (!currentSource()) { + console.log('Newer source exists; leaving version and channel unchanged'); + return false; + } + ensureRelease(tag, `Platform upgrade v${version} (${buildId})`); + const assets = platforms + .flatMap((target) => { + const archive = path.join( + directory, + `qinglong-${commit}-${target}.tar.gz`, + ); + return [archive, archive + '.json']; + }) + .concat( + path.join(directory, 'install-bundle.cjs'), + path.join(directory, 'latest.json'), + ); + const published = lookup(tag); + const missing = []; + const matches = (asset, file) => { + const bytes = fs.readFileSync(file); + return ( + asset?.size === bytes.length && asset.digest === `sha256:${sha256(bytes)}` + ); + }; + for (const file of assets) { + const previous = published.assets.find( + (a) => a.name === path.basename(file), + ); + if (!previous) missing.push(file); + else if (!matches(previous, file)) + throw new Error( + `Immutable release asset differs: ${path.basename(file)}`, + ); + } + if (missing.length) + gh('release', 'upload', tag, ...missing, '--repo', repository); + const uploaded = lookup(tag); + if ( + !assets.every((file) => + matches( + uploaded.assets.find((a) => a.name === path.basename(file)), + file, + ), + ) + ) + throw new Error('Published platform bundle verification failed'); + + // Each channel serializes its image builds and pointer writes as one release. + // A rerun of an earlier workflow is allowed to replace the same + // version/commit; an obsolete source commit must never advance either alias. + const aliases = development + ? ['upgrade-develop'] + : [`upgrade-v${version}`, 'upgrade-master']; + for (const ref of aliases) { + if (!currentSource()) { + console.log('Newer source exists; leaving version and channel unchanged'); + return false; + } + ensureRelease( + ref, + ref === `upgrade-${branch}` + ? `Upgrade channel ${branch}` + : `Platform upgrade v${version}`, + ); + gh( + 'release', + 'upload', + ref, + path.join(directory, 'latest.json'), + '--repo', + repository, + '--clobber', + ); + gh('release', 'edit', ref, '--repo', repository, '--notes', notes); + } + return true; +} +module.exports = { releaseIndex, platforms, publishBundles }; +if (require.main === module) { + const [directory, commit, repository, branch] = process.argv.slice(2); + const version = require('../package.json').version; + if ( + require('js-yaml').load( + fs.readFileSync(path.join(__dirname, '../version.yaml'), 'utf8'), + ).version !== version + ) + throw new Error('Package and image release versions differ'); + publishBundles({ + directory, + commit, + repository, + branch, + version, + buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`, + allowDevelopment: + process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' && + process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true', + }); +} diff --git a/shell/install-bundle.cjs b/shell/install-bundle.cjs new file mode 100644 index 00000000..3473b77a --- /dev/null +++ b/shell/install-bundle.cjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +// Also published as a standalone, bundled installer for devices with Node only. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { randomUUID } = require('node:crypto'); +const { promisify } = require('node:util'); +const execute = promisify(require('node:child_process').execFile); +const { parseArgs } = require('node:util'); +const artifacts = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); +const jobs = require('../cli/src/internal/maintenance/panelUpgrade.cjs'); +const { + nodeRuntimeArgs, +} = require('../cli/src/internal/maintenance/platformBundle.cjs'); + +async function runInstallerMain(argv = process.argv.slice(2)) { + const flags = nodeRuntimeArgs(); + if (!flags.length || process.execArgv.includes('--jitless')) + return installBundle(argv); + const child = require('node:child_process').spawn( + process.execPath, + [...flags, __filename, ...argv], + { stdio: 'inherit', env: process.env }, + ); + const interrupt = () => child.kill('SIGINT'); + const terminate = () => child.kill('SIGTERM'); + process.once('SIGINT', interrupt); + process.once('SIGTERM', terminate); + try { + await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`Offline installer exited: ${signal || code}`)); + }); + }); + } finally { + process.removeListener('SIGINT', interrupt); + process.removeListener('SIGTERM', terminate); + } +} + +async function installBundle(argv = process.argv.slice(2), env = process.env) { + const { values } = parseArgs({ + args: argv, + options: { + root: { type: 'string' }, + file: { type: 'string' }, + 'download-only': { type: 'boolean' }, + install: { type: 'boolean' }, + 'repair-deps': { type: 'boolean' }, + }, + }); + if (!values.file || !(values.root || env.QL_DIR)) + throw new Error( + 'Usage: node install-bundle.cjs --root /path/to/qinglong --file /path/to/bundle.tar.gz [--download-only | --install] [--repair-deps]', + ); + if (values.install && values['download-only']) + throw new Error('Choose --install or --download-only'); + const root = path.resolve(values.root || env.QL_DIR); + const existing = await fs.readdir(root).catch((error) => { + if (error.code !== 'ENOENT') throw error; + return []; + }); + if (values.install && existing.length) + throw new Error('--install requires an empty installation directory'); + if (!values.install) await fs.access(path.join(root, 'shell/share.sh')); + let environment = { + ...env, + QL_DIR: root, + QL_UPGRADE_FILE: path.resolve(values.file), + }; + if (!values.install) { + const captured = await execute( + 'bash', + [ + '--noprofile', + '--norc', + '-c', + '. "$1/shell/share.sh"\n. "$1/shell/env.sh"\nimport_config\nexport QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}"\nenv -0', + 'ql-offline', + root, + ], + { env: environment, maxBuffer: 1024 * 1024 }, + ); + const loaded = Object.fromEntries( + captured.stdout.split('\0').flatMap((line) => { + const match = /^([A-Za-z_][A-Za-z_0-9]*)=([\s\S]*)$/.exec(line); + return match ? [[match[1], match[2]]] : []; + }), + ); + environment = { + ...environment, + ...loaded, + QL_DIR: root, + QL_UPGRADE_FILE: path.resolve(values.file), + }; + } + environment.QL_UPGRADE_REPAIR_DEPS = values['repair-deps'] ? '1' : '0'; + const freshWorkspace = values.install + ? await fs.mkdtemp(path.join(os.tmpdir(), 'ql-offline-install-')) + : undefined; + const tmp = freshWorkspace || path.join(root, '.tmp'); + const controller = new AbortController(); + const abort = () => controller.abort(new Error('离线升级已中断')); + process.once('SIGINT', abort); + process.once('SIGTERM', abort); + try { + const operation = () => + artifacts.withUpgradeLock( + tmp, + async () => { + let state; + const report = async (patch) => { + if (patch.phase) console.log(patch.phase); + if (state) { + state = { + ...state, + ...patch, + pid: process.pid, + updatedAt: Date.now(), + }; + await jobs.saveState(tmp, state); + } + }; + if (!values.install) { + const previous = await jobs.getStatus(tmp); + if (['starting', 'running', 'restarting'].includes(previous.status)) + throw new Error('已有更新或重载正在执行。'); + state = { + id: randomUUID(), + action: values['download-only'] ? 'download' : 'apply', + status: 'running', + phase: 'verifying', + startedAt: Date.now(), + }; + await report({}); + await fs.writeFile( + path.join(tmp, 'panel-upgrade.log'), + 'Offline bundle verification\n', + { mode: 0o600 }, + ); + } + try { + const selected = await artifacts.stageUpgrade({ + root, + tmp, + env: environment, + run: (program, args, capture) => + artifacts.runProcess( + environment, + program, + args, + capture, + controller.signal, + ), + signal: controller.signal, + onProgress: report, + install: async () => { + throw new Error( + 'Offline bundles never install npm dependencies', + ); + }, + }); + if (state) + await report({ + status: 'ready', + phase: 'ready', + ...(await jobs.stagedMetadata(selected)), + }); + if (values['download-only']) { + console.log('更新已就绪;执行 ql reload system 应用更新。'); + return selected; + } + if (values.install) { + // Build beside the destination, then atomically replace only an empty root. + await fs.mkdir(path.dirname(root), { recursive: true }); + const publication = await fs.mkdtemp( + path.join(path.dirname(root), '.qinglong-install-'), + ); + try { + await fs.chmod(publication, 0o755); + await fs.cp(selected.source, publication, { + recursive: true, + verbatimSymlinks: true, + }); + await fs.cp(selected.static, path.join(publication, 'static'), { + recursive: true, + }); + await fs.copyFile( + path.join(publication, '.env.example'), + path.join(publication, '.env'), + require('node:fs').constants.COPYFILE_EXCL, + ); + await fs.chmod(path.join(publication, '.env'), 0o600); + controller.signal.throwIfAborted(); + await fs.rename(publication, root); + } finally { + await fs.rm(publication, { recursive: true, force: true }); + } + await fs.rm(path.dirname(selected.source), { + recursive: true, + force: true, + }); + await fs.rm( + path.join( + tmp, + `upgrade-ready-${artifacts.releaseBranch(environment)}.json`, + ), + { force: true }, + ); + console.log( + `离线安装完成,保留现有 Node ${ + process.version + }。启动服务:QL_DIR=${JSON.stringify( + root, + )} QL_DATA_DIR=${JSON.stringify( + path.join(root, 'data'), + )} bash ${JSON.stringify( + path.join(root, 'shell/start-simplify.sh'), + )}`, + ); + return; + } + const result = await jobs.withPanelLifecycle( + root, + tmp, + environment, + async (hook, prepareSelection) => { + const expected = await jobs.stagedMetadata(selected); + const previous = await jobs + .readRunningBuild(environment) + .catch(() => undefined); + const selection = await prepareSelection?.(selected); + try { + return await artifacts.reloadSystem({ + root, + tmp, + env: environment, + data: environment.dir_data || path.join(root, 'data'), + staticRoot: path.join(root, 'static'), + config: + environment.dir_config || path.join(root, 'data/config'), + lifecycle: { + signal: controller.signal, + stop: async (recover) => { + await report({ + status: 'restarting', + phase: 'restarting', + }); + await hook( + 'stop', + [], + recover ? undefined : controller.signal, + ); + }, + start: async (recover) => { + await hook( + 'start', + [], + recover ? undefined : controller.signal, + ); + await artifacts.waitForHealth( + environment, + undefined, + recover ? undefined : controller.signal, + ); + if (!recover) + await jobs.verifyRunningVersion( + environment, + expected, + previous, + ); + }, + }, + additionalReplacements: selection + ? [{ source: selection.source, target: selection.target }] + : [], + }); + } finally { + if (selection) + await fs.rm(selection.directory, { + recursive: true, + force: true, + }); + } + }, + controller.signal, + ); + await report({ status: 'completed', phase: 'completed' }); + console.log('升级成功,已保留现有 Node 并运行更新后的版本。'); + return result; + } catch (error) { + if (state) await report({ status: 'failed', error: error.message }); + throw error; + } + }, + environment, + ); + return values.install + ? await operation() + : await artifacts.withUpgradeLock( + path.join(tmp, 'panel-job-launch'), + operation, + environment, + ); + } finally { + process.removeListener('SIGINT', abort); + process.removeListener('SIGTERM', abort); + if (freshWorkspace) + await fs.rm(freshWorkspace, { recursive: true, force: true }); + } +} + +module.exports = { installBundle, runInstallerMain }; +if (require.main === module) + runInstallerMain().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index de68281b..bc9bd871 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -52,6 +52,17 @@ make_dir /etc/nginx/conf.d make_dir /run/nginx fix_config +# Slow devices may need more than the worker's readiness window to generate +# first-boot certificates. Reuse the regular initializer before starting workers. +node <<'QL_PREPARE_GRPC_CERTS' +require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js')) + .initGrpcCerts() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); +QL_PREPARE_GRPC_CERTS + pm2 l &>/dev/null log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')" diff --git a/src/components/UpgradeNotice.tsx b/src/components/UpgradeNotice.tsx index d3365e26..facd3d2b 100644 --- a/src/components/UpgradeNotice.tsx +++ b/src/components/UpgradeNotice.tsx @@ -2,7 +2,10 @@ import { CloudDownloadOutlined } from '@ant-design/icons'; import { Modal, Tooltip } from 'antd'; import { useState } from 'react'; import intl from 'react-intl-universal'; -import useUpgrade, { activeStatuses, phaseLabels } from '@/hooks/useUpgrade'; +import useUpgrade, { + activeStatuses, + upgradeStatusLabel, +} from '@/hooks/useUpgrade'; import CheckUpdate from '@/pages/setting/checkUpdate'; export default function UpgradeNotice({ @@ -21,16 +24,15 @@ export default function UpgradeNotice({ if (!showNotice && !open) return null; const text = !connected ? intl.get('连接已断开,正在重新连接') - : job.status === 'failed' - ? intl.get('更新失败') - : intl.get(phaseLabels[job.phase || ''] || '检查更新'); + : intl.get(upgradeStatusLabel(job)); const label = + activeStatuses.includes(job.status) && job.phase === 'downloading' && job.percent != null ? `${text} ${job.percent}%` : text; return ( <> - {showNotice && ( + {showNotice && !open && (