From 0218404d9fdb9084e854e7a6be56d0cd4ef2b26c Mon Sep 17 00:00:00 2001 From: whyour Date: Wed, 7 Oct 2026 17:43:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E3=80=81ql=20update=20=E4=B8=8E=E9=9D=99?= =?UTF-8?q?=E6=80=81=E8=B5=84=E6=BA=90=E7=89=88=E6=9C=AC=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20(#3086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: validate panel upgrades and synchronize versioned static builds * fix: add static-only mirror backfill workflow * fix: avoid lifecycle self-kill and stop cancelled upgrade groups * fix: build images when the shared upgrade engine changes * fix: dispatch upgrade lifecycle through a fixed script * fix: reclaim upgrade locks safely and normalize health probes * fix: match backend base URL normalization exactly --- .github/workflows/build-docker-image.yml | 111 ++- cli/scripts/build.cjs | 8 +- cli/src/internal/maintenance/upgrade.ts | 180 ++--- .../internal/maintenance/upgradeArtifacts.cjs | 657 ++++++++++++++++++ cli/test/internal/operator.test.cjs | 74 +- cli/test/internal/upgradeSelection.test.cjs | 37 + scripts/publish-static.sh | 34 + scripts/sync-static.sh | 20 + shell/update.sh | 111 +-- shell/upgrade.cjs | 8 + test/back/shell-upgrade.test.cjs | 312 +++++++++ test/back/static-mirror.test.cjs | 179 +++++ test/back/upgrade-guards.test.cjs | 285 ++++++++ test/back/upgrade-lifecycle.test.cjs | 288 ++++++++ test/back/upgrade-transaction.test.cjs | 133 ++++ test/helpers/upgrade-fixture.cjs | 30 + 16 files changed, 2195 insertions(+), 272 deletions(-) create mode 100644 cli/src/internal/maintenance/upgradeArtifacts.cjs create mode 100644 scripts/publish-static.sh create mode 100644 scripts/sync-static.sh create mode 100644 shell/upgrade.cjs create mode 100644 test/back/shell-upgrade.test.cjs create mode 100644 test/back/static-mirror.test.cjs create mode 100644 test/back/upgrade-guards.test.cjs create mode 100644 test/back/upgrade-lifecycle.test.cjs create mode 100644 test/back/upgrade-transaction.test.cjs create mode 100644 test/helpers/upgrade-fixture.cjs diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml index 33c244df..07068893 100644 --- a/.github/workflows/build-docker-image.yml +++ b/.github/workflows/build-docker-image.yml @@ -2,26 +2,44 @@ name: Build And Push Docker Image on: push: - paths-ignore: - - "*.md" - # CLI is verified and published separately; images do not build cli/dist. - - "cli/**" - - ".github/workflows/cli-package.yml" + paths: + - "**" + - "!*.md" + # CLI is verified separately; its shared Bash upgrade engine ships in images. + - "!cli/**" + - "cli/src/internal/maintenance/upgradeArtifacts.cjs" + - "!.github/workflows/cli-package.yml" branches: - "master" - "develop" tags: - "v*" workflow_dispatch: + inputs: + static_sync_only: + description: Only synchronize an existing GitHub static snapshot + type: boolean + default: false + static_ref: + description: Static branch or version name, for example v2.22.0 + type: string + default: '' + static_ref_type: + description: Synchronize a branch, or a version branch and Git tag + type: choice + options: [branch, tag] + default: branch permissions: contents: read jobs: validate: + if: ${{ !inputs.static_sync_only }} uses: ./.github/workflows/validate.yml code_gitlab: + if: ${{ !inputs.static_sync_only }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -39,6 +57,7 @@ jobs: git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed" code_gitee: + if: ${{ !inputs.static_sync_only }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -69,6 +88,7 @@ jobs: git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed" build-static: + if: ${{ !inputs.static_sync_only }} needs: validate runs-on: ubuntu-latest steps: @@ -101,62 +121,77 @@ jobs: env: GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_BRANCH: ${{ github.ref_name }} + GITHUB_REF_TYPE: ${{ github.ref_type }} run: | - mkdir -p tmp - cd ./tmp - cp -rf ../static/* ./ - git init -b ${GITHUB_BRANCH} && git add . - git config --local user.name 'github-actions[bot]' - git config --local user.email 'github-actions[bot]@users.noreply.github.com' - git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" - git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} + bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE" static_gitlab: needs: build-static runs-on: ubuntu-latest + concurrency: + group: static-gitlab-${{ github.ref_name }} + cancel-in-progress: false steps: + - uses: actions/checkout@v6 - name: Push qinglong-static to GitLab + env: + STATIC_BRANCH: ${{ github.ref_name }} + STATIC_REF_TYPE: ${{ github.ref_type }} + MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} run: | - set +e + set -euo pipefail mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null - git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror - cd static-mirror - git remote add gitlab git@gitlab.com:whyour/qinglong-static.git 2>/dev/null - git push --force --all gitlab 2>&1 || echo "::warning::GitLab static push failed" - git push --force --tags gitlab 2>&1 || echo "::warning::GitLab static tags push failed" + bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE" static_gitee: needs: build-static runs-on: ubuntu-latest + concurrency: + group: static-gitee-${{ github.ref_name }} + cancel-in-progress: false steps: + - uses: actions/checkout@v6 - name: Push qinglong-static to Gitee env: - GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }} + STATIC_BRANCH: ${{ github.ref_name }} + STATIC_REF_TYPE: ${{ github.ref_type }} + MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} run: | - set +e + set -euo pipefail mkdir -p ~/.ssh - printf '%s\n' "${{ secrets.GITLAB_SSH_PK }}" > ~/.ssh/id_rsa + printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null - git clone --depth=1 --single-branch https://github.com/whyour/qinglong-static.git static-mirror - cd static-mirror - git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null + bash scripts/sync-static.sh git@gitee.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE" - if git push --force --all gitee 2>&1; then - echo "::notice::Gitee static push succeeded" - else - echo "::warning::Push failed, trying to create repo via API..." - curl -sS --connect-timeout 30 --max-time 60 \ - -X POST "https://gitee.com/api/v5/user/repos" \ - -H "Content-Type: application/json" \ - -d '{"name":"qinglong-static","private":"false"}' \ - "?access_token=$GITEE_TOKEN" 2>/dev/null - git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry" - fi - git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed" + sync-existing-static: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mirror: [gitlab, gitee] + concurrency: + group: static-${{ matrix.mirror }}-${{ inputs.static_ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - name: Synchronize existing static snapshot + env: + STATIC_REF: ${{ inputs.static_ref }} + STATIC_REF_TYPE: ${{ inputs.static_ref_type }} + MIRROR_HOST: ${{ matrix.mirror }}.com + MIRROR_SSH_KEY: ${{ secrets.GITLAB_SSH_PK }} + run: | + set -euo pipefail + mkdir -p ~/.ssh + printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -T 10 "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null + bash scripts/sync-static.sh "git@$MIRROR_HOST:whyour/qinglong-static.git" "$STATIC_REF" "$STATIC_REF_TYPE" build-alpine: if: ${{ !startsWith(github.ref, 'refs/tags/') }} diff --git a/cli/scripts/build.cjs b/cli/scripts/build.cjs index 3b7c97fa..0d4d4d92 100644 --- a/cli/scripts/build.cjs +++ b/cli/scripts/build.cjs @@ -13,6 +13,10 @@ execFileSync( ], { stdio: 'inherit' }, ); +fs.copyFileSync( + path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'), + path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'), +); require('esbuild').buildSync({ entryPoints: [path.join(root, 'src/shared/cli/commander.ts')], outfile: path.join(root, 'dist/shared/cli/commander.js'), @@ -45,7 +49,9 @@ const remote = require('esbuild').buildSync({ metafile: true, }); for (const input of Object.keys(remote.metafile.inputs)) { - if (/(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input)) + if ( + /(?:^|[\\/])src[\\/](?:internal|compatibility|developer)[\\/]/.test(input) + ) throw new Error( 'Local implementation leaked into remote npm bundle: ' + input, ); diff --git a/cli/src/internal/maintenance/upgrade.ts b/cli/src/internal/maintenance/upgrade.ts index 1310a050..c877f230 100644 --- a/cli/src/internal/maintenance/upgrade.ts +++ b/cli/src/internal/maintenance/upgrade.ts @@ -8,12 +8,33 @@ import { checkedProcess } from '../runtime/process'; import { operationSignal, withoutCancellation } from '../runtime/cancellation'; import { installPanelDependencies, startPanel, stopPanel } from './operator'; +// The Bash image updater and local CLI must enforce the same build provenance. +const artifacts = require('./upgradeArtifacts.cjs') as { + releaseBranch(env: NodeJS.ProcessEnv): string; + verifyUpgrade(source: string, staticRoot: string): Promise; + waitForHealth( + env: NodeJS.ProcessEnv, + timeout?: number, + signal?: AbortSignal, + ): Promise; + withUpgradeLock(tmp: string, operation: () => Promise): Promise; + stageUpgrade(options: { + root: string; + tmp: string; + env: NodeJS.ProcessEnv; + mirror: 'github' | 'gitee'; + signal?: AbortSignal; + run(program: string, args: string[], capture?: boolean): Promise; + install(source: string): Promise; + }): Promise<{ source: string; static: string }>; + selectedUpgrade( + tmp: string, + branch: string, + ): Promise<{ source: string; static: string }>; +}; + export function releaseBranch(context: LocalContext): string { - return ['develop', 'debian', 'debian-dev'].includes( - context.env.QL_BRANCH ?? '', - ) - ? context.env.QL_BRANCH! - : 'master'; + return artifacts.releaseBranch(context.env); } async function validateTree( @@ -48,134 +69,27 @@ export async function stageUpgrade( context: LocalContext, mirror: 'github' | 'gitee', ): Promise<{ source: string; static: string }> { - await fs.mkdir(context.paths.dir_tmp!, { recursive: true }); - const workspace = await fs.mkdtemp( - path.join(context.paths.dir_tmp!, 'upgrade-'), + return artifacts.withUpgradeLock(context.paths.dir_tmp!, () => + artifacts.stageUpgrade({ + root: context.root, + tmp: context.paths.dir_tmp!, + env: context.env, + mirror, + signal: operationSignal(), + run: (program, args, capture) => + checkedProcess(program, args, { env: context.env, capture }), + install: (source) => installPanelDependencies(context, source), + }), ); - const branch = releaseBranch(context); - try { - for (const repository of ['qinglong', 'qinglong-static']) { - const url = - mirror === 'github' - ? `https://github.com/whyour/${repository}/archive/refs/heads/${branch}.zip` - : `https://gitee.com/whyour/${repository}/repository/archive/${branch}.zip`; - const archive = path.join(workspace, `${repository}.zip`); - await checkedProcess( - 'curl', - [ - '--fail', - '--location', - '--proto', - '=https', - '--proto-redir', - '=https', - '--output', - archive, - url, - ], - { env: context.env }, - ); - const listing = await checkedProcess('unzip', ['-Z1', archive], { - env: context.env, - capture: true, - }); - const expected = `${repository}-${branch}`; - for (const name of listing.stdout.split('\n').filter(Boolean)) { - if ( - name.includes('\\') || - name.includes('\r') || - name.startsWith('/') || - name.split('/').includes('..') || - name.split('/')[0] !== expected - ) - throw new Error(translate(context.env, '升级归档包含无效路径。')); - } - // Reject Unix symlinks before extraction, including links that could redirect - // a later archive entry outside the staging directory. - const modes = await checkedProcess('unzip', ['-Z', '-l', archive], { - env: context.env, - capture: true, - }); - if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line))) - throw new Error(translate(context.env, '升级归档包含符号链接。')); - await checkedProcess('unzip', ['-oq', archive, '-d', workspace], { - env: context.env, - }); - await validateTree(path.join(workspace, expected), context.env); - } - const source = path.join(workspace, `qinglong-${branch}`); - const staticRoot = path.join(workspace, `qinglong-static-${branch}`); - await fs.access(path.join(staticRoot, 'build/app.js')); - const manifest = await fs.readFile(path.join(source, 'package.json')); - const existing = await fs.readFile(path.join(context.root, 'package.json')); - if (!manifest.equals(existing)) - await installPanelDependencies(context, source); - // Publish a marker only after both archives and dependency installation succeed. - await fs.writeFile( - path.join(workspace, 'ready.json'), - JSON.stringify({ source, static: staticRoot }), - { 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, - }, - ); - await fs.rename( - pointer, - path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`), - ); - return { source, static: staticRoot }; - } catch (error) { - await fs.rm(workspace, { recursive: true, force: true }); - throw error; - } } async function selectedUpgrade( context: LocalContext, ): Promise<{ source: string; static: string }> { - const branch = releaseBranch(context); - const fallback = { - source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`), - static: path.join(context.paths.dir_tmp!, `qinglong-static-${branch}`), - }; - const pointer = await fs - .readFile( - path.join(context.paths.dir_tmp!, `upgrade-ready-${branch}.json`), - 'utf8', - ) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === 'ENOENT') return undefined; - throw error; - }); - if (pointer === undefined) return fallback; - const directory: unknown = JSON.parse(pointer)?.directory; - if ( - typeof directory !== 'string' || - !/^upgrade-[A-Za-z0-9_-]+$/.test(directory) - ) - throw new Error(translate(context.env, '暂存升级指针无效。')); - const workspace = path.join(context.paths.dir_tmp!, directory); - const selected = { - source: path.join(workspace, `qinglong-${branch}`), - static: path.join(workspace, `qinglong-static-${branch}`), - }; - for (const location of [workspace, selected.source, selected.static]) { - const stat = await fs.lstat(location); - if (!stat.isDirectory() || stat.isSymbolicLink()) - throw new Error(translate(context.env, '暂存升级目录无效。')); - } - const ready = JSON.parse( - await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'), + return artifacts.selectedUpgrade( + context.paths.dir_tmp!, + releaseBranch(context), ); - if (ready?.source !== selected.source || ready?.static !== selected.static) - throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。')); - return selected; } export interface ReloadLifecycle { @@ -345,8 +259,7 @@ export async function reloadPanel( const staticRoot = selected.static; await validateTree(source, context.env); await validateTree(staticRoot, context.env); - await fs.access(path.join(source, 'package.json')); - await fs.access(path.join(staticRoot, 'build/app.js')); + await artifacts.verifyUpgrade(source, staticRoot); const entries = await fs.readdir(source); const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); const relativeData = path.relative(context.root, context.data); @@ -371,7 +284,16 @@ export async function reloadPanel( if (selection) replacements.push({ source: selection.source, target: selection.target }); try { - return await replaceAndReload(context, replacements); + 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; + }, + }), + ); } finally { if (selection) await fs.rm(selection.directory, { recursive: true, force: true }); diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs new file mode 100644 index 00000000..f3038170 --- /dev/null +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -0,0 +1,657 @@ +// Shared by the image's Bash updater and the optional local CLI. Use only +// installed code to validate downloads; never execute a downloaded verifier. +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { createHash, randomUUID } = require('node:crypto'); +const { spawn } = require('node:child_process'); +const http = require('node:http'); +const { setTimeout: delay } = require('node:timers/promises'); + +const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const releaseBranch = (env) => + ['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master'; + +async function manifestAt(staticRoot) { + const manifest = JSON.parse( + await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'), + ); + if ( + manifest.version !== 1 || + manifest.dirty !== false || + !/^[a-f0-9]{40}$/.test(manifest.sourceCommit) || + !/^[a-f0-9]{64}$/.test(manifest.lockfileSha256) || + !manifest.files || + typeof manifest.files !== 'object' || + Array.isArray(manifest.files) + ) + throw new Error('Invalid or dirty build manifest / 构建清单无效。'); + for (const required of ['build/app.js', 'dist/index.html']) + if (!Object.hasOwn(manifest.files, required)) + throw new Error(`Missing build output / 缺少构建产物: ${required}`); + return manifest; +} + +async function verifyUpgrade(source, staticRoot) { + const manifest = await manifestAt(staticRoot); + if ( + hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !== + manifest.lockfileSha256 + ) + throw new Error( + 'Source and build lockfiles differ / 源码与构建依赖不一致。', + ); + await fs.access(path.join(source, 'package.json')); + await fs.access(path.join(source, 'sample/config.sample.sh')); + const files = {}; + async function visit(directory, prefix = '') { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const name = prefix + entry.name; + if (name === 'build-info.json') continue; + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(filename, `${name}/`); + else if (entry.isFile()) files[name] = hash(await fs.readFile(filename)); + else + throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`); + } + } + await visit(staticRoot); + const actual = Object.keys(files).sort(), + expected = Object.keys(manifest.files).sort(); + if ( + JSON.stringify(actual) !== JSON.stringify(expected) || + actual.some((name) => files[name] !== manifest.files[name]) + ) + throw new Error( + 'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。', + ); + return manifest; +} + +async function withUpgradeLock(tmp, operation) { + await fs.mkdir(tmp, { recursive: true }); + const lock = path.join(tmp, 'upgrade.lock'); + const token = randomUUID(); + const owner = `owner-${process.pid}-${token}`; + const candidate = path.join(tmp, `upgrade-lock-${token}`); + let acquired = false; + await fs.mkdir(candidate, { mode: 0o700 }); + try { + // Publish a nonempty directory atomically: a live lock can never be + // replaced, and a crash before publication cannot leave an ownerless lock. + await fs.writeFile(path.join(candidate, owner), '', { + flag: 'wx', + mode: 0o600, + }); + for (;;) { + try { + await fs.rename(candidate, lock); + acquired = true; + break; + } catch (error) { + if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; + } + let entries; + try { + entries = await fs.readdir(lock); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + if (!entries.length) continue; + const heldOwner = entries[0]; + const match = /^owner-([1-9]\d*)-[a-f0-9-]{36}$/.exec(heldOwner); + let pid = match ? Number(match[1]) : 0; + if (entries.length === 1 && heldOwner === 'pid') { + try { + pid = Number(await fs.readFile(path.join(lock, heldOwner), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + } + let stale = false; + if ( + entries.length === 1 && + Number.isSafeInteger(pid) && + pid > 0 && + pid <= 0x7fffffff + ) { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') stale = true; + } + } + if (!stale) + throw new Error( + 'Another upgrade is running / 已有更新或重载正在执行。', + ); + // Removing the observed unique marker is a compare-and-delete. A + // contender cannot unlink the marker belonging to a replacement lock. + // Legacy pid files are supported; new owners never reuse that filename. + try { + await fs.unlink(path.join(lock, heldOwner)); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + return await operation(); + } finally { + if (acquired) { + await fs.unlink(path.join(lock, owner)).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await fs.rmdir(lock).catch((error) => { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) + throw error; + }); + } + await fs.rm(candidate, { recursive: true, force: true }); + } +} + +async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { + const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); + const branch = releaseBranch(env); + try { + async function extract(repository, ref) { + const archive = path.join(workspace, `${repository}.zip`); + const url = + mirror === 'github' + ? `https://github.com/whyour/${repository}/archive/${ref}.zip` + : `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`; + await run('curl', [ + '--fail', + '--location', + '--proto', + '=https', + '--proto-redir', + '=https', + '--connect-timeout', + '30', + '--max-time', + '600', + '--output', + archive, + url, + ]); + const listing = await run('unzip', ['-Z1', archive], true); + const expected = `${repository}-${ref}`; + const names = listing.stdout.split('\n').filter(Boolean); + if ( + !names.length || + names.some( + (name) => + name.includes('\\') || + name.includes('\r') || + name.startsWith('/') || + name.split('/').includes('..') || + name.split('/')[0] !== expected, + ) + ) + throw new Error('Invalid archive paths / 升级归档路径无效。'); + const modes = await run('unzip', ['-Z', '-l', archive], true); + if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line))) + throw new Error('Archive contains symlinks / 升级归档包含符号链接。'); + await run('unzip', ['-oq', archive, '-d', workspace]); + return path.join(workspace, expected); + } + // Static branches are mutable snapshots. Their manifest pins the exact + // source revision, avoiding a race between two independent branch reads. + const staticRoot = await extract('qinglong-static', branch); + const manifest = await manifestAt(staticRoot); + const downloadedSource = await extract('qinglong', manifest.sourceCommit); + const source = path.join(workspace, `qinglong-${branch}`); + await fs.rename(downloadedSource, source); + await verifyUpgrade(source, staticRoot); + let dependenciesChanged = false; + for (const name of ['package.json', 'pnpm-lock.yaml']) { + const current = await fs + .readFile(path.join(root, name)) + .catch((error) => { + if (error.code !== 'ENOENT') throw error; + return Buffer.alloc(0); + }); + if (!(await fs.readFile(path.join(source, name))).equals(current)) + dependenciesChanged = true; + } + if ( + !(await fs.stat(path.join(root, 'node_modules')).catch(() => undefined)) + ) + dependenciesChanged = true; + if (dependenciesChanged) { + await install(source); + await fs.access(path.join(source, 'node_modules')); + await verifyUpgrade(source, staticRoot); + } + signal?.throwIfAborted(); + const selected = { source, static: staticRoot }; + await fs.writeFile( + path.join(workspace, 'ready.json'), + JSON.stringify({ + ...selected, + sourceCommit: manifest.sourceCommit, + dependenciesChanged, + }), + { 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 }, + ); + const readyPointer = path.join(tmp, `upgrade-ready-${branch}.json`); + const previous = await fs + .readFile(readyPointer, 'utf8') + .catch(() => undefined); + signal?.throwIfAborted(); + await fs.rename(pointer, readyPointer); + // Retire only our previous successful stage, after publishing its replacement. + try { + const directory = previous && JSON.parse(previous).directory; + if ( + typeof directory === 'string' && + /^upgrade-[A-Za-z0-9_-]+$/.test(directory) && + directory !== path.basename(workspace) + ) + await fs.rm(path.join(tmp, directory), { + recursive: true, + force: true, + }); + } catch { + /* Cleanup cannot invalidate the newly published stage. */ + } + return selected; + } catch (error) { + await fs.rm(workspace, { recursive: true, force: true }); + throw error; + } +} + +async function selectedUpgrade(tmp, branch) { + const pointer = JSON.parse( + await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), + ); + if ( + typeof pointer.directory !== 'string' || + !/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory) + ) + throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。'); + const workspace = path.join(tmp, pointer.directory); + const selected = { + source: path.join(workspace, `qinglong-${branch}`), + static: path.join(workspace, `qinglong-static-${branch}`), + }; + for (const directory of [workspace, selected.source, selected.static]) { + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error('Invalid staging directory / 暂存目录无效。'); + } + const ready = JSON.parse( + await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'), + ); + if (ready.source !== selected.source || ready.static !== selected.static) + throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。'); + const manifest = await verifyUpgrade(selected.source, selected.static); + if (ready.sourceCommit !== manifest.sourceCommit) + throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。'); + if (ready.dependenciesChanged) + await fs.access(path.join(selected.source, 'node_modules')); + return selected; +} + +async function replaceAndReload(replacements, lifecycle) { + const changed = []; + let attemptedStart = false; + lifecycle.signal?.throwIfAborted(); + try { + await lifecycle.stop(); + for (const { source, target } of replacements) { + lifecycle.signal?.throwIfAborted(); + await fs.mkdir(path.dirname(target), { recursive: true }); + const backup = `${target}.ql-backup-${randomUUID()}`; + const existed = !!(await fs.lstat(target).catch((error) => { + if (error.code !== 'ENOENT') throw error; + return undefined; + })); + if (existed) { + try { + await fs.rename(target, backup); + } catch (error) { + if (error.code !== 'EXDEV') throw error; + try { + await fs.cp(target, backup, { + recursive: true, + verbatimSymlinks: true, + }); + } catch (copyError) { + await fs.rm(backup, { recursive: true, force: true }); + throw copyError; + } + changed.push({ target, backup, existed }); + await fs.rm(target, { recursive: true }); + } + } + if (changed.at(-1)?.target !== target) + changed.push({ target, backup, existed }); + await fs.cp(source, target, { + recursive: true, + verbatimSymlinks: true, + force: false, + errorOnExist: true, + }); + } + lifecycle.signal?.throwIfAborted(); + attemptedStart = true; + await lifecycle.start(); + lifecycle.signal?.throwIfAborted(); + } 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(); + for (const item of changed.reverse()) { + await fs.rm(item.target, { recursive: true, force: true }); + if (item.existed) { + try { + await fs.rename(item.backup, item.target); + } catch (restoreError) { + if (restoreError.code !== 'EXDEV') throw restoreError; + await fs.cp(item.backup, item.target, { + recursive: true, + verbatimSymlinks: true, + }); + await fs.rm(item.backup, { recursive: true }); + } + } + } + await lifecycle.start(true); + throw error; + } + for (const item of changed) + if (item.existed) + await fs + .rm(item.backup, { recursive: true, force: true }) + .catch(() => + console.error(`Retained backup / 保留备份: ${item.backup}`), + ); +} + +async function reloadSystem({ + root, + data, + tmp, + staticRoot, + config, + env, + lifecycle, +}) { + const selected = await selectedUpgrade(tmp, releaseBranch(env)); + const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); + const relative = path.relative(root, data); + if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) + reserved.add(relative.split(path.sep)[0]); + const replacements = (await fs.readdir(selected.source)) + .filter((name) => !reserved.has(name)) + .map((name) => ({ + source: path.join(selected.source, name), + target: path.join(root, name), + })); + replacements.push( + { source: selected.static, target: staticRoot }, + { + source: path.join(selected.source, 'sample/config.sample.sh'), + target: path.join(config, 'config.sample.sh'), + }, + ); + await replaceAndReload(replacements, lifecycle); + // Cleanup after a committed installation must not report a failed update. + await fs + .rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), { + force: true, + }) + .catch(() => {}); + await fs + .rm(path.dirname(selected.source), { recursive: true, force: true }) + .catch(() => {}); +} + +async function waitForHealth(env, timeout = 60000, signal) { + const port = Number(env.QlPort || 5700); + let base = env.QlBaseUrl || ''; + if (base && !base.startsWith('/')) base = `/${base}`; + if (base.endsWith('/')) base = base.slice(0, -1); + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + signal?.throwIfAborted(); + const healthy = await new Promise((resolve) => { + let timer; + const finish = (healthy) => { + clearTimeout(timer); + resolve(healthy); + }; + const req = http.get( + { hostname: '127.0.0.1', port, path: `${base}/api/health`, signal }, + (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + if (body.length > 65536) req.destroy(); + }); + res.on('end', () => { + try { + const result = JSON.parse(body); + finish( + res.statusCode === 200 && + result.code === 200 && + result.data?.status === 'ok', + ); + } catch { + finish(false); + } + }); + res.on('error', () => finish(false)); + }, + ); + // Socket inactivity timeouts do not bound a response that keeps streaming. + timer = setTimeout( + () => req.destroy(new Error('Health probe timed out')), + Math.min(1000, Math.max(1, deadline - Date.now())), + ); + req.on('error', () => finish(false)); + }); + signal?.throwIfAborted(); + if (healthy) return; + const remaining = deadline - Date.now(); + if (remaining > 0) { + try { + await delay(Math.min(500, remaining), undefined, { signal }); + } catch (error) { + signal?.throwIfAborted(); + throw error; + } + } + } + throw new Error('Backend failed health check / 后端启动检查失败。'); +} + +function runProcess(env, program, args, capture = false, signal) { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const child = spawn(program, args, { + env, + detached: process.platform !== 'win32', + // Pipes keep close pending until children have released their output. + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let aborted = false; + let failure; + let escalation; + const kill = (name) => { + if (!child.pid) return; + try { + process.kill( + process.platform === 'win32' ? child.pid : -child.pid, + name, + ); + } catch (error) { + if (error.code === 'EPERM') { + child.kill(name); + return; + } + if (error.code !== 'ESRCH') throw error; + } + }; + const abort = () => { + if (aborted) return; + aborted = true; + kill('SIGTERM'); + escalation = setTimeout(() => kill('SIGKILL'), 1000); + escalation.unref(); + }; + child.stdout.on('data', (chunk) => { + if (capture) stdout += chunk; + else process.stdout.write(chunk); + }); + child.stderr.on('data', (chunk) => process.stderr.write(chunk)); + child.once('error', (error) => { + failure = error; + }); + child.once('exit', () => { + // The shell can exit before a descendant that ignores SIGTERM. + if (aborted) kill('SIGKILL'); + }); + child.once('close', (code) => { + if (aborted) kill('SIGKILL'); + clearTimeout(escalation); + signal?.removeEventListener('abort', abort); + if (aborted) reject(signal.reason); + else if (failure) reject(failure); + else if (code === 0) resolve({ stdout }); + else reject(new Error(`${program} failed (exit ${code})`)); + }); + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + }); +} + +async function withLifecycleHooks(root, tmp, env, operation) { + const helpers = await fs.readFile(path.join(root, 'shell/share.sh'), 'utf8'); + const environment = await fs.readFile( + path.join(root, 'shell/env.sh'), + 'utf8', + ); + const directory = await fs.mkdtemp(path.join(tmp, 'upgrade-hooks-')); + try { + // Keep the installed functions available through replacement and rollback. + // Their pkill patterns must never become part of the bash command line. + const snapshot = path.join(directory, 'lifecycle.sh'); + await fs.writeFile( + snapshot, + `${helpers}\n${environment}\nimport_config +case "$1" in + stop) delete_pm2 ;; + start) reload_pm2 ;; + install) npm_install_2 "$2"; exit "$exit_status" ;; + *) printf '%s\\n' 'Unknown lifecycle operation' >&2; exit 2 ;; +esac +`, + { + flag: 'wx', + mode: 0o600, + }, + ); + const hook = (operation, args = [], signal) => + runProcess( + env, + 'bash', + ['--', snapshot, operation, ...args], + false, + signal, + ); + return await operation(hook); + } finally { + await fs + .rm(directory, { recursive: true, force: true }) + .catch(() => + console.error( + `Retained lifecycle snapshot / 保留生命周期快照: ${directory}`, + ), + ); + } +} + +async function main() { + const env = process.env, + root = env.QL_DIR; + if (!root || !path.isAbsolute(root)) + throw new Error('QL_DIR must be absolute.'); + const tmp = env.dir_tmp || path.join(root, '.tmp'); + const controller = new AbortController(); + for (const name of ['SIGINT', 'SIGTERM']) + process.on(name, () => + controller.abort(new Error(`Upgrade interrupted (${name})`)), + ); + const run = (program, args, capture) => + runProcess(env, program, args, capture, controller.signal); + await withUpgradeLock(tmp, () => + withLifecycleHooks(root, tmp, env, async (hook) => { + const lifecycle = { + signal: controller.signal, + stop: () => hook('stop'), + start: async (recover) => { + await hook('start', [], recover ? undefined : controller.signal); + await waitForHealth( + env, + 60000, + recover ? undefined : controller.signal, + ); + }, + }; + if (process.argv[2] === 'update') { + await stageUpgrade({ + root, + tmp, + env, + mirror: process.argv[3], + run, + signal: controller.signal, + install: (source) => hook('install', [source], controller.signal), + }); + console.log('更新包下载及校验成功...'); + 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, + }); + console.log('更新及启动检查成功...'); + }), + ); +} + +module.exports = { + releaseBranch, + verifyUpgrade, + withUpgradeLock, + stageUpgrade, + selectedUpgrade, + replaceAndReload, + reloadSystem, + waitForHealth, + runProcess, + withLifecycleHooks, + main, +}; +if (require.main === module) + main().catch((error) => { + console.error(`更新失败: ${error.message}`); + process.exitCode = 1; + }); diff --git a/cli/test/internal/operator.test.cjs b/cli/test/internal/operator.test.cjs index f5d19e75..5e3ca6a4 100644 --- a/cli/test/internal/operator.test.cjs +++ b/cli/test/internal/operator.test.cjs @@ -15,8 +15,17 @@ const { reloadPanel, } = require('../../dist/internal/maintenance/upgrade'); const { parse } = require('../helpers/commands.cjs'); +const { + writeManifest, + sourceCommit, +} = require('../../../test/helpers/upgrade-fixture.cjs'); async function fixture(t) { + t.mock.method( + require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), + 'waitForHealth', + async () => {}, + ); const root = await fs.realpath( await fs.mkdtemp(path.join(os.tmpdir(), 'ql-operator-')), ); @@ -119,14 +128,27 @@ test('PM2 restarts retain configured ports without mutating legacy environment', ctx.env.CALLS = path.join(ctx.root, 'ports.jsonl'); ctx.env.BACK_PORT = '5600'; ctx.env.GRPC_PORT = '5400'; - await stub(ctx, 'pm2', 'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");'); + await stub( + ctx, + 'pm2', + 'require("node:fs").appendFileSync(process.env.CALLS, JSON.stringify([process.env.BACK_PORT,process.env.GRPC_PORT])+"\\n");', + ); await startPanel(ctx); ctx.env.QlPort = '5799'; ctx.env.QlGrpcPort = '5599'; await startPanel(ctx); - assert.deepEqual((await fs.readFile(ctx.env.CALLS, 'utf8')).trim().split('\n').map(JSON.parse), [ - ['5700', '5500'], ['5700', '5500'], ['5799', '5599'], ['5799', '5599'], - ]); + assert.deepEqual( + (await fs.readFile(ctx.env.CALLS, 'utf8')) + .trim() + .split('\n') + .map(JSON.parse), + [ + ['5700', '5500'], + ['5700', '5500'], + ['5799', '5599'], + ['5799', '5599'], + ], + ); assert.equal(ctx.env.BACK_PORT, '5600'); assert.equal(ctx.env.GRPC_PORT, '5400'); }); @@ -199,24 +221,41 @@ test('upgrade staging extracts both archives before publishing readiness and rej const payloads = path.join(ctx.root, 'payloads'); const archives = path.join(ctx.root, 'archives'); await fs.mkdir(archives); - await fs.mkdir(path.join(payloads, 'qinglong-master'), { recursive: true }); + await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}`), { + recursive: true, + }); await fs.mkdir(path.join(payloads, 'qinglong-static-master/build'), { recursive: true, }); const manifest = '{"name":"fixture"}'; await fs.writeFile(path.join(ctx.root, 'package.json'), manifest); await fs.writeFile( - path.join(payloads, 'qinglong-master/package.json'), + path.join(payloads, `qinglong-${sourceCommit}/package.json`), manifest, ); await fs.writeFile( path.join(payloads, 'qinglong-static-master/build/app.js'), 'console.log("fixture")', ); + await fs.mkdir(path.join(payloads, `qinglong-${sourceCommit}/sample`)); + await fs.writeFile( + path.join(payloads, `qinglong-${sourceCommit}/sample/config.sample.sh`), + 'new sample', + ); + writeManifest( + path.join(payloads, `qinglong-${sourceCommit}`), + path.join(payloads, 'qinglong-static-master'), + ); + await fs.writeFile(path.join(ctx.root, 'pnpm-lock.yaml'), 'fixture lock\n'); + await fs.mkdir(path.join(ctx.root, 'node_modules')); for (const repo of ['qinglong', 'qinglong-static']) execFileSync( '/usr/bin/zip', - ['-qry', path.join(archives, `${repo}.zip`), `${repo}-master`], + [ + '-qry', + path.join(archives, `${repo}.zip`), + `${repo}-${repo === 'qinglong' ? sourceCommit : 'master'}`, + ], { cwd: payloads }, ); ctx.env.ARCHIVES = archives; @@ -225,18 +264,6 @@ test('upgrade staging extracts both archives before publishing readiness and rej 'curl', 'const fs=require("node:fs"),p=require("node:path"),args=process.argv.slice(2),out=args[args.indexOf("--output")+1]; fs.copyFileSync(p.join(process.env.ARCHIVES,p.basename(out)),out);', ); - await fs.mkdir(path.join(payloads, 'qinglong-master/sample'), { - recursive: true, - }); - await fs.writeFile( - path.join(payloads, 'qinglong-master/sample/config.sample.sh'), - 'new sample', - ); - execFileSync( - '/usr/bin/zip', - ['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'], - { cwd: payloads }, - ); const staged = await stageUpgrade(ctx, 'github'); assert.equal( await fs.readFile(path.join(staged.source, 'package.json'), 'utf8'), @@ -249,15 +276,15 @@ test('upgrade staging extracts both archives before publishing readiness and rej 'utf8', ), ), - staged, + { ...staged, sourceCommit, dependenciesChanged: false }, ); await fs.symlink( '../../outside', - path.join(payloads, 'qinglong-master/escape'), + path.join(payloads, `qinglong-${sourceCommit}/escape`), ); execFileSync( '/usr/bin/zip', - ['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'], + ['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`], { cwd: payloads }, ); await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/); @@ -728,7 +755,7 @@ test('invalid or incomplete staged pointers cannot stop the installed panel', as reloadPanel(ctx, 'system'), (error) => error.code === 'ENOENT' && - error.path === path.join(ctx.paths.dir_tmp, 'qinglong-master'), + error.path === path.join(ctx.paths.dir_tmp, 'upgrade-ready-master.json'), ); }); @@ -783,6 +810,7 @@ test('system upgrade preserves installed dotenv bytes and permissions even when ); await fs.writeFile(path.join(staticRoot, 'build/app.js'), ''); await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n'); + writeManifest(source, staticRoot); const target = path.join(ctx.root, '.env'); const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n'; await fs.writeFile(target, original, { mode: 0o600 }); diff --git a/cli/test/internal/upgradeSelection.test.cjs b/cli/test/internal/upgradeSelection.test.cjs index 85007ebc..1a3a1801 100644 --- a/cli/test/internal/upgradeSelection.test.cjs +++ b/cli/test/internal/upgradeSelection.test.cjs @@ -6,10 +6,16 @@ const os = require('node:os'); const { createContext } = require('../../dist/internal/runtime/context'); const { reloadPanel } = require('../../dist/internal/maintenance/upgrade'); const operator = require('../../dist/internal/maintenance/operator'); +const { writeManifest } = require('../../../test/helpers/upgrade-fixture.cjs'); const selectedLoader = '// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n'; async function fixture(t) { + t.mock.method( + require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), + 'waitForHealth', + async () => {}, + ); const base = await fs.mkdtemp( path.join(os.tmpdir(), 'ql-upgrade-selection-'), ); @@ -43,6 +49,7 @@ async function fixture(t) { await fs.writeFile(incoming, '// original released loader'); await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old'); await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new'); + writeManifest(source, staticRoot); return { ctx, source, staticRoot, loader, incoming }; } @@ -104,3 +111,33 @@ test('unsupported target and unrecognized loader fail before service stop', asyn assert.equal(stopped, false); assert.equal(await fs.readFile(f.loader, 'utf8'), 'unrecognized'); }); + +test('successful process start with failed health check rolls back the installed version', async (t) => { + const f = await fixture(t); + t.mock.method(operator, 'stopPanel', async () => {}); + t.mock.method(operator, 'startPanel', async () => ({ manager: 'fixture' })); + let probes = 0; + t.mock.method( + require('../../dist/internal/maintenance/upgradeArtifacts.cjs'), + 'waitForHealth', + async () => { + if (++probes === 1) throw new Error('health check failed'); + assert.equal( + await fs.readFile( + path.join(f.ctx.paths.dir_static, 'build/app.js'), + 'utf8', + ), + 'old', + ); + }, + ); + await assert.rejects( + reloadPanel(f.ctx, 'system', { source: f.source, static: f.staticRoot }), + /health check failed/, + ); + assert.equal(probes, 2); + assert.equal( + await fs.readFile(path.join(f.ctx.root, 'package.json'), 'utf8'), + '{"version":"2.19.0"}', + ); +}); diff --git a/scripts/publish-static.sh b/scripts/publish-static.sh new file mode 100644 index 00000000..7d3066ee --- /dev/null +++ b/scripts/publish-static.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +destination=$1 +ref=$2 +ref_type=$3 +git check-ref-format "refs/heads/$ref" +script_dir=$(cd "$(dirname "$0")" && pwd) +# Publish only a complete artifact from the checked-out source commit. +node "$script_dir/../docker/verify-build.cjs" +if [[ "$ref_type" == 'tag' ]]; then + version=$(awk '$1 == "version:" { print $2; exit }' version.yaml) + if [[ "$ref" != "v$version" ]]; then + echo "::error::Release tag $ref does not match version.yaml ($version)" + exit 1 + fi +elif [[ "$ref_type" != 'branch' ]]; then + echo "::error::Unsupported ref type: $ref_type" + exit 1 +fi +workspace=$(mktemp -d) +trap 'rm -rf "$workspace"' EXIT +cp -a static/. "$workspace/" +git init -b "$ref" "$workspace" +git -C "$workspace" config user.name 'github-actions[bot]' +git -C "$workspace" config user.email 'github-actions[bot]@users.noreply.github.com' +git -C "$workspace" add -f . +git -C "$workspace" commit --allow-empty -m "Build static from $(git rev-parse HEAD)" +refs=("refs/heads/$ref:refs/heads/$ref") +if [[ "$ref_type" == 'tag' ]]; then + # Keep version branches for existing archive URLs and publish real Git tags. + git -C "$workspace" tag "$ref" + refs+=("refs/tags/$ref:refs/tags/$ref") +fi +git -C "$workspace" push --force --quiet "$destination" "${refs[@]}" diff --git a/scripts/sync-static.sh b/scripts/sync-static.sh new file mode 100644 index 00000000..4abdd9ef --- /dev/null +++ b/scripts/sync-static.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Static branches are orphan snapshots. Fetch complete commits for the branch +# that was built: a shallow/default-branch clone is rejected by mirror servers. +set -euo pipefail +destination=$1 +branch=$2 +ref_type=${3:-branch} +git check-ref-format "refs/heads/$branch" +workspace=$(mktemp -d) +trap 'rm -rf "$workspace"' EXIT +git init --bare "$workspace" +fetch_refs=("+refs/heads/$branch:refs/heads/$branch") +push_refs=("refs/heads/$branch:refs/heads/$branch") +if [[ "$ref_type" == 'tag' ]]; then + fetch_refs+=("+refs/tags/$branch:refs/tags/$branch") + push_refs+=("refs/tags/$branch:refs/tags/$branch") +fi +git -C "$workspace" fetch --no-tags "${QL_STATIC_SOURCE:-https://github.com/whyour/qinglong-static.git}" \ + "${fetch_refs[@]}" +git -C "$workspace" push --force "$destination" "${push_refs[@]}" diff --git a/shell/update.sh b/shell/update.sh index f5069cf6..db8f3eb5 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -283,98 +283,44 @@ usage() { } reload_qinglong() { - echo -e "[reload_qinglong] deleting Triggered at $(date)" >>${dir_log}/reload.log + local reload_target="${1}" + if [[ "$reload_target" == 'system' ]]; then + QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}" node "$dir_shell/upgrade.cjs" reload + return $? + fi + + # Data import and a plain service restart retain their existing behavior. + echo -e "[reload_qinglong] deleting Triggered at $(date)" >>"${dir_log}/reload.log" sleep 3 delete_pm2 - echo -e "[reload_qinglong] deleted Triggered at $(date)" >>${dir_log}/reload.log - - local reload_target="${1}" - local primary_branch="master" - if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then - primary_branch="${QL_BRANCH}" - fi - - if [[ "$reload_target" == 'system' ]]; then - rm -rf ${dir_root}/back ${dir_root}/cli ${dir_root}/docker ${dir_root}/sample ${dir_root}/shell ${dir_root}/src - mv -f ${dir_tmp}/qinglong-${primary_branch}/* ${dir_root}/ - rm -rf $dir_static/* - mv -f ${dir_tmp}/qinglong-static-${primary_branch}/* ${dir_static}/ - cp -f $file_config_sample $dir_config/config.sample.sh - fi - if [[ "$reload_target" == 'data' ]]; then - rm -rf ${dir_data}/* - mv -f ${dir_tmp}/data/* ${dir_data}/ + rm -rf "${dir_data:?}"/* + mv -f "$dir_tmp"/data/* "$dir_data"/ || return $? fi - echo -e "[reload_qinglong] starting Triggered at $(date)" >>${dir_log}/reload.log reload_pm2 - echo -e "[reload_qinglong] started Triggered at $(date)\n" >>${dir_log}/reload.log } ## 更新 qinglong update_qinglong() { - rm -rf ${dir_tmp}/* local mirror="gitee" - local downloadQLUrl="https://gitee.com/whyour/qinglong/repository/archive" - local downloadStaticUrl="https://gitee.com/whyour/qinglong-static/repository/archive" - local githubStatus=$(curl -s --noproxy "*" -m 2 -IL "https://google.com" | grep 200) - if [[ ! -z $githubStatus ]]; then + if curl --fail --silent --noproxy "*" --max-time 2 --head "https://github.com" >/dev/null; then mirror="github" - downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads" - downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads" - fi - t '使用 %s 源更新...\n' "${mirror}" - - local primary_branch="master" - if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then - primary_branch="${QL_BRANCH}" - fi - - wget -cqO "${dir_tmp}/ql.zip" "${downloadQLUrl}/${primary_branch}.zip" - exit_status=$? - - if [[ $exit_status -eq 0 ]]; then - t '更新青龙源文件成功...\n' - - unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp} - - update_qinglong_static - else - t '更新青龙源文件失败,请检查网络...\n' fi + t '使用 %s 源更新...\n' "$mirror" + QlPort="${ql_port:-${QlPort:-5700}}" QlBaseUrl="${ql_base_url:-${QlBaseUrl:-/}}" node "$dir_shell/upgrade.cjs" update "$mirror" "$needRestart" } -update_qinglong_static() { - wget -cqO "${dir_tmp}/static.zip" "${downloadStaticUrl}/${primary_branch}.zip" - exit_status=$? - - if [[ $exit_status -eq 0 ]]; then - t '更新青龙静态资源成功...\n' - unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp} - - check_update_dep +# Preserve the updater's status through both tee and the final timing output. +run_update_logged() ( + set -o pipefail + if [[ "$real_time" == 'true' ]]; then + "$@" + elif [[ "$no_tee" == 'true' ]]; then + "$@" >>"$file_path" 2>&1 else - t '更新青龙静态资源失败,请检查网络...\n' + "$@" 2>&1 | tee -a "$file_path" fi -} - -check_update_dep() { - t '\n开始检测依赖...\n' - if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then - npm_install_2 "${dir_tmp}/qinglong-${primary_branch}" - fi - - if [[ $exit_status -eq 0 ]]; then - t '\n依赖检测安装成功...\n' - t '更新包下载成功...\n' - - if [[ "$needRestart" == 'true' ]]; then - reload_qinglong "system" - fi - else - t '\n依赖检测安装失败,请检查网络...\n' - fi -} +) ## 对比脚本 diff_scripts() { @@ -525,7 +471,7 @@ main() { if [[ "$p1" == "log" ]]; then show_service_logs "${@:2}" - return $? + return 0 fi local log_dir="${p1}" @@ -554,14 +500,17 @@ main() { [[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp" + local command_status=0 case $p1 in update) fix_config local needRestart=${p2:-"true"} - eval update_qinglong $cmd + run_update_logged update_qinglong + command_status=$? ;; reload) - eval reload_qinglong "$p2" $cmd + run_update_logged reload_qinglong "$p2" + command_status=$? ;; extra) eval run_extra_shell $cmd @@ -620,9 +569,9 @@ main() { if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd fi + return "$command_status" } import_config "$@" main "$@" - -exit 0 +exit $? diff --git a/shell/upgrade.cjs b/shell/upgrade.cjs new file mode 100644 index 00000000..811e8583 --- /dev/null +++ b/shell/upgrade.cjs @@ -0,0 +1,8 @@ +// The image updater and local CLI share one implementation and build contract. +const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); +module.exports = upgrade; +if (require.main === module) + upgrade.main().catch((error) => { + console.error(`更新失败: ${error.message}`); + process.exitCode = 1; + }); diff --git a/test/back/shell-upgrade.test.cjs b/test/back/shell-upgrade.test.cjs new file mode 100644 index 00000000..cc4f75f8 --- /dev/null +++ b/test/back/shell-upgrade.test.cjs @@ -0,0 +1,312 @@ +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 http = require('node:http'); +const { spawn, execFileSync } = require('node:child_process'); +const { + writeManifest, + sourceCommit, +} = require('../helpers/upgrade-fixture.cjs'); +const { waitForHealth, withUpgradeLock } = require('../../shell/upgrade.cjs'); + +const share = ` +export dir_root="$QL_DIR" dir_shell="$QL_DIR/shell" dir_tmp="$QL_DIR/.tmp" +export dir_data="$QL_DIR/data" dir_static="$QL_DIR/static" dir_log="$QL_DIR/data/log" dir_config="$QL_DIR/data/config" +load_ql_envs() { :; } +import_config() { :; } +fix_config() { :; } +make_dir() { mkdir -p "$1"; } +format_timestamp() { date +%s; } +format_time() { printf '%s' "$2"; } +t() { printf "$@"; } +npm_install_2() { + printf installed > "$QL_DIR/installed" + exit_status="$DEP_STATUS" + mkdir -p "$1/node_modules" + printf new > "$1/node_modules/dependency" +} +delete_pm2() { printf stopped >> "$QL_DIR/stops"; } +reload_pm2() { + printf started >> "$QL_DIR/starts" + if [[ "$FAIL_START" == 'true' ]] && [[ ! -f "$QL_DIR/failed-once" ]]; then + touch "$QL_DIR/failed-once"; return 1 + fi +} +`; + +function write(root, name, content) { + const filename = path.join(root, name); + fs.mkdirSync(path.dirname(filename), { recursive: true }); + fs.writeFileSync(filename, content); + return filename; +} + +function run(program, args, options) { + return new Promise((resolve, reject) => { + const cp = spawn(program, args, { + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', + stderr = ''; + cp.stdout.on('data', (chunk) => { + stdout += chunk; + }); + cp.stderr.on('data', (chunk) => { + stderr += chunk; + }); + cp.on('error', reject); + cp.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +async function fixture(t, options = {}) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-upgrade-')); + t.after(() => fs.rmSync(base, { recursive: true, force: true })); + const root = path.join(base, 'panel'), + payload = path.join(base, 'payload'), + archives = path.join(base, 'archives'); + const branch = ['develop', 'debian-dev'].includes(options.branch) + ? 'develop' + : 'master'; + const source = path.join(payload, `qinglong-${sourceCommit}`), + staticRoot = path.join(payload, `qinglong-static-${branch}`); + const packageJson = '{"version":"2.22.0"}'; + for (const target of [root, source]) { + write(target, 'shell/update.sh', fs.readFileSync('shell/update.sh')); + write(target, 'shell/upgrade.cjs', fs.readFileSync('shell/upgrade.cjs')); + write( + target, + 'cli/src/internal/maintenance/upgradeArtifacts.cjs', + fs.readFileSync('cli/src/internal/maintenance/upgradeArtifacts.cjs'), + ); + write(target, 'shell/share.sh', share); + write(target, 'shell/api.sh', ''); + write(target, 'shell/env.sh', ''); + write(target, 'sample/config.sample.sh', 'sample'); + write(target, 'package.json', packageJson); + write( + target, + 'pnpm-lock.yaml', + target === source && options.lockChange ? 'new lock' : 'old lock', + ); + write(target, 'back/app.ts', target === source ? 'new' : 'old'); + write( + target, + 'version.yaml', + target === source ? 'version: 2.22.0' : 'version: 2.21.0', + ); + } + write(root, 'node_modules/dependency', 'old'); + write(root, '.env', 'private setting'); + write(root, 'data/config/config.sh', 'custom'); + write(root, 'static/build/app.js', 'old backend'); + write(root, 'static/dist/index.html', 'old frontend'); + write(root, '.tmp/import-in-progress', 'keep'); + write(staticRoot, 'build/app.js', 'new backend'); + write(staticRoot, 'dist/index.html', ''); + write(staticRoot, 'dist/umi.js', 'dashboard'); + write(source, '.env.example', 'new example'); + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + if (options.manifest === 'missing') fs.unlinkSync(manifestFile); + else if (options.manifest === 'dirty') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, dirty: true }), + ); + else if (options.manifest === 'lock') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, lockfileSha256: '0'.repeat(64) }), + ); + else if (options.manifest === 'commit') + fs.writeFileSync( + manifestFile, + JSON.stringify({ ...manifest, sourceCommit: 'b'.repeat(40) }), + ); + else if (options.manifest === 'checksum') + write(staticRoot, 'dist/umi.js', 'stale frontend'); + else if (options.manifest === 'extra') + write(staticRoot, 'build/stale.js', 'stale backend'); + else if (options.manifest === 'frontend') + fs.unlinkSync(path.join(staticRoot, 'dist/index.html')); + fs.mkdirSync(archives); + for (const repo of ['qinglong', 'qinglong-static']) { + const archive = path.join(archives, `${repo}.zip`); + execFileSync( + 'zip', + [ + '-qr', + archive, + `${repo}-${repo === 'qinglong' ? sourceCommit : branch}`, + ], + { cwd: payload }, + ); + if (options.corrupt === repo) fs.writeFileSync(archive, 'invalid zip'); + } + const curl = write( + base, + 'bin/curl', + `#!${process.execPath} +const fs=require('node:fs'),path=require('node:path'),args=process.argv.slice(2); +if(args.includes('--head')) process.exit(7); +const repo=args.at(-1).includes('/qinglong-static/')?'qinglong-static':'qinglong'; +fs.appendFileSync(process.env.TRACE,args.at(-1)+'\\n'); +if(process.env.FAIL_DOWNLOAD===repo) process.exit(22); +fs.copyFileSync(path.join(process.env.ARCHIVES,repo+'.zip'),args[args.indexOf('--output')+1]); +`, + ); + fs.chmodSync(curl, 0o755); + const server = http.createServer((req, res) => + res.end('{"code":200,"data":{"status":"ok"}}'), + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + const env = { + ...process.env, + QL_DIR: root, + QL_BRANCH: options.branch || 'master', + PATH: `${path.join(base, 'bin')}:${process.env.PATH}`, + QlPort: String(server.address().port), + ARCHIVES: archives, + TRACE: path.join(base, 'trace'), + real_time: options.tee ? 'false' : 'true', + FAIL_DOWNLOAD: options.download || '', + DEP_STATUS: options.depFail ? '1' : '0', + FAIL_START: options.failStart ? 'true' : 'false', + }; + return { + root, + env, + execute: (...args) => + run('bash', [path.join(root, 'shell/update.sh'), ...args], { env }), + read: (name) => fs.readFileSync(path.join(root, name), 'utf8'), + }; +} + +for (const options of [ + { download: 'qinglong' }, + { download: 'qinglong-static', tee: true }, + { corrupt: 'qinglong' }, + { corrupt: 'qinglong-static', tee: true }, + ...[ + 'missing', + 'dirty', + 'lock', + 'commit', + 'checksum', + 'extra', + 'frontend', + ].map((manifest) => ({ manifest })), + { lockChange: true, depFail: true, tee: true }, +]) + test(`failed update preserves the running panel: ${JSON.stringify( + options, + )}`, async (t) => { + const f = await fixture(t, options); + const result = await f.execute('update'); + assert.notEqual(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + }); + +for (const branch of ['master', 'develop', 'debian', 'debian-dev']) + test(`download-only then reload installs matching artifacts and lockfile dependencies: ${branch}`, async (t) => { + const f = await fixture(t, { branch, lockChange: true, tee: true }); + let result = await f.execute('update', 'false'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); + assert.match( + fs.readFileSync(f.env.TRACE, 'utf8'), + new RegExp(`qinglong/repository/archive/${sourceCommit}\\.zip`), + ); + result = await f.execute('reload', 'system'); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(f.read('node_modules/dependency'), 'new'); + assert.equal(f.read('pnpm-lock.yaml'), 'new lock'); + assert.equal(f.read('static/dist/umi.js'), 'dashboard'); + assert.equal(f.read('back/app.ts'), 'new'); + assert.equal(f.read('.env'), 'private setting'); + assert.equal(f.read('.env.example'), 'new example'); + assert.equal(f.read('data/config/config.sh'), 'custom'); + assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [ + 'import-in-progress', + ]); + assert.ok( + !fs.readdirSync(f.root).some((name) => name.includes('ql-backup')), + ); + }); + +test('a failed start rolls code, frontend and node_modules back and exits nonzero through tee', async (t) => { + const f = await fixture(t, { lockChange: true, failStart: true, tee: true }); + const result = await f.execute('update'); + assert.notEqual(result.code, 0); + assert.equal(f.read('version.yaml'), 'version: 2.21.0'); + assert.equal(f.read('node_modules/dependency'), 'old'); + assert.equal(f.read('static/dist/index.html'), 'old frontend'); + assert.equal(f.read('starts'), 'startedstarted'); + assert.ok(!fs.readdirSync(f.root).some((name) => name.includes('ql-backup'))); +}); + +test('reload revalidates staged files before stopping the old panel', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const directory = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + write( + f.root, + `.tmp/${directory}/qinglong-static-master/dist/umi.js`, + 'corrupt', + ); + assert.notEqual((await f.execute('reload', 'system')).code, 0); + assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); +}); + +test('a new successful stage retires only the previous ready workspace', async (t) => { + const f = await fixture(t); + assert.equal((await f.execute('update', 'false')).code, 0); + const previous = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.equal((await f.execute('update', 'false')).code, 0); + const current = JSON.parse( + f.read('.tmp/upgrade-ready-master.json'), + ).directory; + assert.notEqual(previous, current); + assert.equal(fs.existsSync(path.join(f.root, '.tmp', previous)), false); + assert.equal(f.read('.tmp/import-in-progress'), 'keep'); +}); + +test('failed health checks and concurrent upgrades are rejected', async (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-lock-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(fs.readdirSync(tmp), []); + const server = http.createServer((req, res) => { + res.statusCode = 503; + res.end('{"code":503}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + await assert.rejects( + waitForHealth({ QlPort: String(server.address().port) }, 20), + /health check/, + ); +}); diff --git a/test/back/static-mirror.test.cjs b/test/back/static-mirror.test.cjs new file mode 100644 index 00000000..6934d96b --- /dev/null +++ b/test/back/static-mirror.test.cjs @@ -0,0 +1,179 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { writeManifest } = require('../helpers/upgrade-fixture.cjs'); + +test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'), + target = path.join(tmp, 'target.git'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('init', '--bare', target); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + const script = path.resolve('scripts/sync-static.sh'); + const env = { ...process.env, QL_STATIC_SOURCE: source }; + for (const branch of ['master', 'develop']) { + git('-C', source, 'checkout', '--orphan', branch); + fs.writeFileSync(path.join(source, 'build-info.json'), branch); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', branch); + execFileSync('bash', [script, target, branch], { env, stdio: 'pipe' }); + assert.equal( + git('-C', target, 'rev-parse', branch), + git('-C', source, 'rev-parse', branch), + ); + assert.equal( + git('-C', target, 'rev-parse', '--is-shallow-repository'), + 'false', + ); + } + fs.writeFileSync( + path.join(target, 'hooks/pre-receive'), + '#!/bin/sh\nexit 1\n', + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(source, 'build-info.json'), 'next'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'next'); + assert.notEqual( + spawnSync('bash', [script, target, 'develop'], { env }).status, + 0, + ); +}); + +test('mirror jobs select the built branch, retain hidden artifacts and propagate failures', () => { + const yaml = require('js-yaml'); + const workflow = yaml.load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + for (const name of ['static_gitee', 'static_gitlab']) { + const job = workflow.jobs[name], + step = job.steps.find((step) => step.run); + assert.equal(job.needs, 'build-static'); + assert.equal(step.env.STATIC_BRANCH, '${{ github.ref_name }}'); + assert.equal(step.env.STATIC_REF_TYPE, '${{ github.ref_type }}'); + assert.match(step.run, /set -euo pipefail/); + assert.match(step.run, /bash scripts\/sync-static.sh.*"\$STATIC_BRANCH"/); + assert.doesNotMatch(step.run, /warning|set \+e|--depth/); + } + const publish = workflow.jobs['build-static'].steps.find( + (step) => step.name === 'copy to static repo', + ); + assert.equal(publish.env.GITHUB_REF_TYPE, '${{ github.ref_type }}'); + assert.match(publish.run, /bash scripts\/publish-static.sh/); + assert.match( + fs.readFileSync('scripts/publish-static.sh', 'utf8'), + /cp -a static\/\./, + ); +}); + +test('version static snapshots retain a matching branch and Git tag on all three remotes', (t) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-version-static-')); + t.after(() => fs.rmSync(tmp, { recursive: true, force: true })); + const source = path.join(tmp, 'source'); + const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim(); + git('init', source); + git('-C', source, 'config', 'user.name', 'Fixture'); + git('-C', source, 'config', 'user.email', 'fixture@example.invalid'); + fs.writeFileSync(path.join(source, 'version.yaml'), 'version: 2.22.0\n'); + fs.writeFileSync(path.join(source, '.gitignore'), 'static/\n'); + fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'fixture lock\n'); + git('-C', source, 'add', '.'); + git('-C', source, 'commit', '-m', 'source'); + const staticRoot = path.join(source, 'static'); + for (const filename of [ + 'build/app.js', + 'dist/index.html', + 'dist/.well-known/config', + ]) { + fs.mkdirSync(path.dirname(path.join(staticRoot, filename)), { + recursive: true, + }); + fs.writeFileSync(path.join(staticRoot, filename), filename); + } + writeManifest(source, staticRoot); + const manifestFile = path.join(staticRoot, 'build-info.json'); + const manifest = JSON.parse(fs.readFileSync(manifestFile)); + manifest.sourceCommit = git('-C', source, 'rev-parse', 'HEAD'); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + const remotes = ['github', 'gitlab', 'gitee'].map((name) => + path.join(tmp, `${name}.git`), + ); + for (const remote of remotes) git('init', '--bare', remote); + const publish = path.resolve('scripts/publish-static.sh'), + sync = path.resolve('scripts/sync-static.sh'); + const options = { cwd: source, stdio: 'pipe', env: process.env }; + execFileSync('bash', [publish, remotes[0], 'master', 'branch'], options); + execFileSync('bash', [publish, remotes[0], 'v2.22.0', 'tag'], options); + for (const remote of remotes.slice(1)) + execFileSync('bash', [sync, remote, 'v2.22.0', 'tag'], { + ...options, + env: { ...process.env, QL_STATIC_SOURCE: remotes[0] }, + }); + const snapshot = git('-C', remotes[0], 'rev-parse', 'refs/heads/v2.22.0'); + for (const remote of remotes) { + assert.equal( + git('-C', remote, 'rev-parse', 'refs/heads/v2.22.0'), + snapshot, + ); + assert.equal(git('-C', remote, 'rev-parse', 'refs/tags/v2.22.0'), snapshot); + assert.equal( + git('-C', remote, 'show', 'v2.22.0:dist/.well-known/config'), + 'dist/.well-known/config', + ); + } + assert.notEqual( + spawnSync('bash', [publish, remotes[0], 'v2.23.0', 'tag'], options).status, + 0, + ); + assert.equal( + spawnSync('git', [ + '-C', + remotes[0], + 'rev-parse', + '--verify', + 'refs/tags/v2.23.0', + ]).status, + 128, + ); +}); + +test('static backfill uses existing snapshots and skips all source, build and image publication jobs', () => { + const workflow = require('js-yaml').load( + fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'), + ); + assert.equal( + workflow.on.workflow_dispatch.inputs.static_sync_only.default, + false, + ); + const job = workflow.jobs['sync-existing-static']; + assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/); + assert.deepEqual(job.strategy.matrix.mirror, ['gitlab', 'gitee']); + const sync = job.steps.find((step) => step.run); + assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}'); + assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}'); + assert.match(sync.run, /bash scripts\/sync-static.sh/); + assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/); + for (const name of ['validate', 'code_gitlab', 'code_gitee', 'build-static']) + assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/); + // Downstream jobs require the skipped build; none bypass dependency failures. + for (const name of [ + 'static_gitlab', + 'static_gitee', + 'build-alpine', + 'build-debian', + 'build-alpine310', + 'build-debian310', + ]) { + assert.equal(workflow.jobs[name].needs, 'build-static'); + assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/); + } +}); diff --git a/test/back/upgrade-guards.test.cjs b/test/back/upgrade-guards.test.cjs new file mode 100644 index 00000000..247368c8 --- /dev/null +++ b/test/back/upgrade-guards.test.cjs @@ -0,0 +1,285 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { randomUUID } = require('node:crypto'); +const { spawn } = require('node:child_process'); +const { withUpgradeLock, waitForHealth } = require('../../shell/upgrade.cjs'); + +async function temporary(t) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-upgrade-guards-')); + t.after(() => fs.rm(tmp, { recursive: true, force: true })); + return tmp; +} + +for (const legacy of [true, false]) + test( + `concurrent reclamation of a ${ + legacy ? 'legacy lock' : 'unique owner lock' + } cannot remove a new owner`, + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const owner = legacy ? 'pid' : `owner-99999999-${randomUUID()}`; + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, owner), legacy ? '99999999' : ''); + const original = { + readFile: fs.readFile, + readdir: fs.readdir, + rm: fs.rm, + unlink: fs.unlink, + }; + let readCount = 0, + removeCount = 0, + active = 0, + maximum = 0; + let readyReaders, firstEntered, release; + const readers = new Promise((resolve) => { + readyReaders = resolve; + }); + const entered = new Promise((resolve) => { + firstEntered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const synchronize = async () => { + if (++readCount === 2) readyReaders(); + await readers; + }; + t.mock.method(fs, 'readFile', async (...args) => { + const value = await original.readFile(...args); + if (legacy && args[0] === path.join(lock, owner) && readCount < 2) + await synchronize(); + return value; + }); + t.mock.method(fs, 'readdir', async (...args) => { + const value = await original.readdir(...args); + if (!legacy && args[0] === lock && readCount < 2) await synchronize(); + return value; + }); + t.mock.method(fs, 'rm', async (...args) => { + if (args[0] === lock && ++removeCount === 2) await entered; + return original.rm(...args); + }); + t.mock.method(fs, 'unlink', async (...args) => { + if (args[0] === path.join(lock, owner) && ++removeCount === 2) + await entered; + return original.unlink(...args); + }); + const operation = async () => { + maximum = Math.max(maximum, ++active); + firstEntered(); + if (active === 1) await held; + active--; + }; + const attempts = [ + withUpgradeLock(tmp, operation), + withUpgradeLock(tmp, operation), + ].map((attempt) => + attempt.then( + () => ({ ok: true }), + (error) => ({ error }), + ), + ); + try { + await entered; + const contender = await Promise.race(attempts); + assert.match(contender.error?.message || '', /Another upgrade/); + assert.equal(maximum, 1); + assert.equal((await original.readdir(lock)).length, 1); + } finally { + release(); + await Promise.all(attempts); + } + assert.deepEqual(await original.readdir(tmp), []); + }, + ); + +test('an empty abandoned lock is acquired atomically and a failed operation releases its owner', async (t) => { + const tmp = await temporary(t); + await fs.mkdir(path.join(tmp, 'upgrade.lock')); + await assert.rejects( + withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => assert.fail('concurrent operation')), + /Another upgrade/, + ); + throw new Error('operation failed'); + }), + /operation failed/, + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); +}); + +test('release never deletes another owner after the original lock was replaced', async (t) => { + const tmp = await temporary(t); + const lock = path.join(tmp, 'upgrade.lock'); + const replacement = `owner-${process.pid}-${randomUUID()}`; + await withUpgradeLock(tmp, async () => { + await fs.rm(lock, { recursive: true }); + await fs.mkdir(lock); + await fs.writeFile(path.join(lock, replacement), ''); + }); + assert.deepEqual(await fs.readdir(lock), [replacement]); +}); + +test( + 'a lock left by a killed process can be reclaimed by a new process', + { timeout: 5000 }, + async (t) => { + const tmp = await temporary(t); + const worker = path.join(tmp, 'worker.cjs'); + await fs.writeFile( + worker, + ` +const { withUpgradeLock } = require(${JSON.stringify( + path.resolve('shell/upgrade.cjs'), + )}); +withUpgradeLock(process.argv[2], async () => { + process.stdout.write('owned\\n'); + await new Promise(() => setInterval(() => {}, 1000)); +}).catch(error => { process.stderr.write(error.message); process.exitCode = 1; }); +`, + ); + const child = spawn(process.execPath, [worker, tmp], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', resolve); + }); + await new Promise((resolve, reject) => { + child.stdout.once('data', resolve); + child.once('error', reject); + child.once('exit', () => + reject(new Error('worker exited before acquiring its lock')), + ); + }); + child.kill('SIGKILL'); + await closed; + await withUpgradeLock(tmp, async () => { + await assert.rejects( + withUpgradeLock(tmp, async () => {}), + /Another upgrade/, + ); + }); + assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']); + }, +); + +async function healthServer(t, handler) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after( + () => + new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }), + ); + return String(server.address().port); +} + +test('health checks normalize supported URL prefixes exactly like the backend', async (t) => { + const requests = []; + const port = await healthServer(t, (req, res) => { + requests.push(req.url); + if ( + ['/api/health', '/panel/api/health', '/panel//api/health'].includes( + req.url, + ) + ) + res.end('{"code":200,"data":{"status":"ok"}}'); + else { + res.statusCode = 404; + res.end('{}'); + } + }); + for (const prefix of [ + '', + '/', + 'panel', + '/panel', + 'panel/', + '/panel/', + 'panel//', + '/panel//', + ]) + await waitForHealth({ QlPort: port, QlBaseUrl: prefix }, 100); + assert.deepEqual(requests, [ + '/api/health', + '/api/health', + ...Array(4).fill('/panel/api/health'), + ...Array(2).fill('/panel//api/health'), + ]); +}); + +test( + 'health timeout bounds a continuously streaming response', + { timeout: 3000 }, + async (t) => { + const port = await healthServer(t, (req, res) => { + const timer = setInterval(() => res.write(' '), 10); + res.once('close', () => clearInterval(timer)); + }); + const began = Date.now(); + await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/); + assert.ok( + Date.now() - began < 1000, + 'deadline must not depend on socket inactivity', + ); + }, +); + +for (const streaming of [false, true]) + test( + `cancellation interrupts ${ + streaming ? 'an active response' : 'the retry delay' + } and releases the upgrade lock`, + { timeout: 3000 }, + async (t) => { + const tmp = await temporary(t); + const controller = new AbortController(); + const reason = new Error('cancelled health check'); + const port = await healthServer(t, (req, res) => { + if (streaming) res.write('{'); + else { + res.statusCode = 503; + res.end('{}'); + } + setTimeout(() => controller.abort(reason), 30); + }); + const began = Date.now(); + await assert.rejects( + withUpgradeLock(tmp, () => + waitForHealth({ QlPort: port }, 5000, controller.signal), + ), + (error) => error === reason, + ); + assert.ok( + Date.now() - began < 400, + 'cancel must interrupt the current wait', + ); + await withUpgradeLock(tmp, async () => {}); + assert.deepEqual(await fs.readdir(tmp), []); + }, + ); + +test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => { + for (const body of ['{}', '{', ' '.repeat(65537)]) { + const port = await healthServer(t, (req, res) => res.end(body)); + await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/); + } +}); diff --git a/test/back/upgrade-lifecycle.test.cjs b/test/back/upgrade-lifecycle.test.cjs new file mode 100644 index 00000000..2fe9bc1d --- /dev/null +++ b/test/back/upgrade-lifecycle.test.cjs @@ -0,0 +1,288 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const { + runProcess, + withLifecycleHooks, + withUpgradeLock, +} = require('../../shell/upgrade.cjs'); + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function assertStopped(pid) { + for (let attempt = 0; attempt < 50; attempt++) { + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === 'ESRCH') return; + throw error; + } + const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { + encoding: 'utf8', + }); + // Linux PID 1 can retain a dead orphan until it is reaped. It can also + // disappear between kill(0) and ps; neither state is a running installer. + if ( + (state.status === 1 && !state.stdout.trim()) || + /^Z/.test(state.stdout.trim()) + ) + return; + assert.equal(state.status, 0, state.stderr); + await delay(20); + } + assert.fail(`test process ${pid} is still running`); +} + +async function fixture(t, directory) { + const base = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-lifecycle-'), + ); + t.after(() => fs.rm(base, { recursive: true, force: true })); + const root = directory ? path.join(base, directory) : base; + const tmp = path.join(root, '.tmp'); + await fs.mkdir(tmp, { recursive: true }); + await fs.mkdir(path.join(root, 'shell/lang'), { recursive: true }); + await fs.writeFile(path.join(root, 'shell/lang/zh.sh'), ''); + await fs.writeFile( + path.join(root, 'shell/env.sh'), + await fs.readFile('shell/env.sh'), + ); + return { root, tmp, env: { ...process.env, QL_DIR: root } }; +} + +test('installed stop/start hooks do not match their own pkill and survive shell replacement', async (t) => { + const f = await fixture(t); + // Only the test's random path can match; never signal a running panel. + const marker = `review/${randomUUID()}/app.js`; + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { printf '%s\\n' "$*" >> "$QL_DIR/pm2-calls"; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await fs.writeFile(path.join(f.root, 'shell/share.sh'), 'exit 99'); + await hook('stop'); + await hook('start'); + assert.match( + await fs.readFile(path.join(f.root, 'pm2-calls'), 'utf8'), + /startOrGracefulReload/, + ); + }), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test( + 'the installed Node fallback starts successfully and its stop hook kills only the test backend', + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const marker = `review/${randomUUID()}/app.js`; + let pid; + t.after(() => { + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll( + 'static/build/app.js', + marker, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `${helpers} +pm2() { return 1; } +${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''} +`, + ); + await fs.mkdir(path.dirname(path.join(f.root, marker)), { + recursive: true, + }); + await fs.mkdir(path.join(f.root, 'data/log'), { recursive: true }); + await fs.writeFile( + path.join(f.root, marker), + `require('node:fs').writeFileSync(process.env.QL_DIR + '/backend-pid', String(process.pid)); setInterval(() => {}, 1000);`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => { + await hook('start'); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'backend-pid'), 'utf8'), + ); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'fallback backend started'); + process.kill(pid, 0); + await hook('stop'); + await assertStopped(pid); + }), + ); + }, +); + +for (const { redirect, ignoreShell } of [ + { redirect: false, ignoreShell: false }, + { redirect: true, ignoreShell: false }, + { redirect: false, ignoreShell: true }, +]) + test( + `cancelled installation stops descendants before releasing its lock (${JSON.stringify( + { redirect, ignoreShell }, + )})`, + { timeout: 10000 }, + async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + let pid; + t.after(() => { + controller.abort(); + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }); + const worker = path.join(f.root, 'installer.cjs'); + await fs.writeFile( + worker, + ` +const fs = require('node:fs'), path = require('node:path'); +process.on('SIGTERM', () => {}); +fs.writeFileSync(path.join(process.env.QL_DIR, 'installer-pid'), String(process.pid)); +setInterval(() => fs.appendFileSync(path.join(process.env.QL_DIR, 'heartbeat'), 'x'), 20); +`, + ); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + `import_config() { :; } +npm_install_2() { + ${ignoreShell ? "trap '' TERM" : ':'} + "$TEST_NODE" "$1"${redirect ? ' >/dev/null 2>&1' : ''} + exit_status=$? +} +`, + ); + const operation = withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_NODE: process.execPath }, + (hook) => hook('install', [worker], controller.signal), + ), + ); + // Attach rejection handling before aborting the in-flight operation. + const rejection = assert.rejects(operation, /cancelled installation/); + for (let attempt = 0; attempt < 150; attempt++) { + try { + pid = Number( + await fs.readFile(path.join(f.root, 'installer-pid'), 'utf8'), + ); + await fs.access(path.join(f.root, 'heartbeat')); + break; + } catch { + await delay(20); + } + } + assert.ok(pid, 'installer started'); + await fs.access(path.join(f.tmp, 'upgrade.lock')); + controller.abort(new Error('cancelled installation')); + await rejection; + assert.deepEqual(await fs.readdir(f.tmp), []); + const before = await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'); + await delay(100); + assert.equal( + await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'), + before, + ); + await assertStopped(pid); + }, + ); + +test('an already cancelled command does not spawn and a spawn failure releases the upgrade lock', async (t) => { + const f = await fixture(t); + const controller = new AbortController(); + controller.abort(new Error('cancelled before spawn')); + const script = path.join(f.root, 'never-run.sh'); + await fs.writeFile(script, 'touch "$QL_DIR/spawned"'); + await assert.rejects( + runProcess(f.env, 'bash', ['--', script], false, controller.signal), + /cancelled before spawn/, + ); + await assert.rejects(fs.access(path.join(f.root, 'spawned')), { + code: 'ENOENT', + }); + await assert.rejects( + withUpgradeLock(f.tmp, () => + runProcess(f.env, path.join(f.root, 'missing-program'), []), + ), + { code: 'ENOENT' }, + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); + +test('fixed lifecycle dispatch treats spaces, quotes and command substitutions in paths as data', async (t) => { + const special = + 'panel spaces \u0027"; $(touch "$TEST_MARKER"); `touch "$TEST_MARKER"`'; + const f = await fixture(t, special); + const marker = path.join(f.root, 'injected'); + const source = path.join(f.root, 'source ' + special); + await fs.writeFile( + path.join(f.root, 'shell/share.sh'), + ` +import_config() { :; } +delete_pm2() { printf stopped > "$QL_DIR/stopped"; } +reload_pm2() { printf started > "$QL_DIR/started"; } +npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; } +`, + ); + await withUpgradeLock(f.tmp, () => + withLifecycleHooks( + f.root, + f.tmp, + { ...f.env, TEST_MARKER: marker }, + async (hook) => { + await hook('stop'); + await hook('start'); + await hook('install', [source]); + assert.equal( + await fs.readFile(path.join(f.root, 'stopped'), 'utf8'), + 'stopped', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'started'), 'utf8'), + 'started', + ); + assert.equal( + await fs.readFile(path.join(f.root, 'installed'), 'utf8'), + source, + ); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + await assert.rejects(hook('touch "$TEST_MARKER"'), /exit 2/); + await assert.rejects(fs.access(marker), { code: 'ENOENT' }); + }, + ), + ); + assert.deepEqual(await fs.readdir(f.tmp), []); +}); diff --git a/test/back/upgrade-transaction.test.cjs b/test/back/upgrade-transaction.test.cjs new file mode 100644 index 00000000..3df18f2f --- /dev/null +++ b/test/back/upgrade-transaction.test.cjs @@ -0,0 +1,133 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { replaceAndReload } = require('../../shell/upgrade.cjs'); + +async function fixture(t) { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql-upgrade-transaction-'), + ); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = path.join(root, 'source'), + target = path.join(root, 'target'); + for (const directory of [source, target]) await fs.mkdir(directory); + await fs.writeFile(path.join(source, 'version'), 'new'); + await fs.writeFile(path.join(target, 'version'), 'old'); + return { root, source, target }; +} + +for (const failure of ['backup', 'remove', 'replacement', 'start']) + test(`Bash transaction restores the full original after an overlay ${failure} failure`, async (t) => { + const f = await fixture(t); + const rename = fs.rename, + cp = fs.cp, + rm = fs.rm; + t.mock.method(fs, 'rename', async (source, target) => { + if (source === f.target || source.includes('.ql-backup-')) { + const error = new Error('overlay rename'); + error.code = 'EXDEV'; + throw error; + } + return rename(source, target); + }); + let injected = false; + t.mock.method(fs, 'cp', async (source, target, options) => { + if ( + !injected && + ((failure === 'backup' && source === f.target) || + (failure === 'replacement' && source === f.source)) + ) { + injected = true; + await fs.mkdir(target, { recursive: true }); + await fs.writeFile(path.join(target, 'partial'), 'partial'); + throw new Error('copy failed'); + } + return cp(source, target, options); + }); + t.mock.method(fs, 'rm', async (target, options) => { + if (!injected && failure === 'remove' && target === f.target) { + injected = true; + await fs.unlink(path.join(target, 'version')); + throw new Error('remove failed'); + } + return rm(target, options); + }); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => {}, + start: async () => { + if (++starts === 1 && failure === 'start') + throw new Error('start failed'); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + }, + }), + /failed/, + ); + assert.deepEqual(await fs.readdir(f.target), ['version']); + assert.ok( + !(await fs.readdir(f.root)).some((name) => name.includes('.ql-backup-')), + ); + }); + +test('interrupted upgrade recovers the previous service outside cancellation', async (t) => { + const f = await fixture(t), + controller = new AbortController(); + let starts = 0; + await assert.rejects( + replaceAndReload([f], { + signal: controller.signal, + stop: async () => {}, + start: async (recover) => { + if (++starts === 1) { + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); + controller.abort(new Error('interrupted')); + } else { + assert.equal(recover, true); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'old', + ); + } + }, + }), + /interrupted/, + ); + assert.equal(starts, 2); +}); + +test('failed stop during rollback retains backups without overwriting running files', async (t) => { + const f = await fixture(t); + let stops = 0; + await assert.rejects( + replaceAndReload([f], { + stop: async () => { + if (++stops === 2) throw new Error('stop failed'); + }, + start: async () => { + throw new Error('start failed'); + }, + }), + /stop failed/, + ); + const backup = (await fs.readdir(f.root)).find((name) => + name.includes('.ql-backup-'), + ); + assert.ok(backup); + assert.equal( + await fs.readFile(path.join(f.root, backup, 'version'), 'utf8'), + 'old', + ); + assert.equal( + await fs.readFile(path.join(f.target, 'version'), 'utf8'), + 'new', + ); +}); diff --git a/test/helpers/upgrade-fixture.cjs b/test/helpers/upgrade-fixture.cjs new file mode 100644 index 00000000..251174fd --- /dev/null +++ b/test/helpers/upgrade-fixture.cjs @@ -0,0 +1,30 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { collectBuildFiles } = require('../../docker/build-manifest.cjs'); +const sourceCommit = 'a'.repeat(40); + +function writeManifest(source, staticRoot) { + const lockfile = path.join(source, 'pnpm-lock.yaml'); + if (!fs.existsSync(lockfile)) fs.writeFileSync(lockfile, 'fixture lock\n'); + fs.mkdirSync(path.join(staticRoot, 'dist'), { recursive: true }); + if (!fs.existsSync(path.join(staticRoot, 'dist/index.html'))) + fs.writeFileSync( + path.join(staticRoot, 'dist/index.html'), + '
', + ); + fs.writeFileSync( + path.join(staticRoot, 'build-info.json'), + JSON.stringify({ + version: 1, + dirty: false, + sourceCommit, + lockfileSha256: createHash('sha256') + .update(fs.readFileSync(lockfile)) + .digest('hex'), + files: collectBuildFiles(staticRoot), + }), + ); +} + +module.exports = { writeManifest, sourceCommit };