mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix: 修复页面升级、ql update 与静态资源版本同步 (#3086)
* 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
This commit is contained in:
@@ -2,26 +2,44 @@ name: Build And Push Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths:
|
||||||
- "*.md"
|
- "**"
|
||||||
# CLI is verified and published separately; images do not build cli/dist.
|
- "!*.md"
|
||||||
- "cli/**"
|
# CLI is verified separately; its shared Bash upgrade engine ships in images.
|
||||||
- ".github/workflows/cli-package.yml"
|
- "!cli/**"
|
||||||
|
- "cli/src/internal/maintenance/upgradeArtifacts.cjs"
|
||||||
|
- "!.github/workflows/cli-package.yml"
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
- "develop"
|
- "develop"
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
uses: ./.github/workflows/validate.yml
|
uses: ./.github/workflows/validate.yml
|
||||||
|
|
||||||
code_gitlab:
|
code_gitlab:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -39,6 +57,7 @@ jobs:
|
|||||||
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
git push --force --tags gitlab 2>&1 || echo "::warning::GitLab tags push failed"
|
||||||
|
|
||||||
code_gitee:
|
code_gitee:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -69,6 +88,7 @@ jobs:
|
|||||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
git push --force --tags gitee 2>&1 || echo "::warning::Gitee tags push failed"
|
||||||
|
|
||||||
build-static:
|
build-static:
|
||||||
|
if: ${{ !inputs.static_sync_only }}
|
||||||
needs: validate
|
needs: validate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -101,62 +121,77 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||||
GITHUB_BRANCH: ${{ github.ref_name }}
|
GITHUB_BRANCH: ${{ github.ref_name }}
|
||||||
|
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p tmp
|
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||||
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}
|
|
||||||
|
|
||||||
static_gitlab:
|
static_gitlab:
|
||||||
needs: build-static
|
needs: build-static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: static-gitlab-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
- name: Push qinglong-static to GitLab
|
- 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: |
|
run: |
|
||||||
set +e
|
set -euo pipefail
|
||||||
mkdir -p ~/.ssh
|
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
|
chmod 600 ~/.ssh/id_rsa
|
||||||
ssh-keyscan -T 10 gitlab.com >> ~/.ssh/known_hosts 2>/dev/null
|
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
|
bash scripts/sync-static.sh git@gitlab.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||||
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"
|
|
||||||
|
|
||||||
static_gitee:
|
static_gitee:
|
||||||
needs: build-static
|
needs: build-static
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: static-gitee-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
- name: Push qinglong-static to Gitee
|
- name: Push qinglong-static to Gitee
|
||||||
env:
|
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: |
|
run: |
|
||||||
set +e
|
set -euo pipefail
|
||||||
mkdir -p ~/.ssh
|
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
|
chmod 600 ~/.ssh/id_rsa
|
||||||
ssh-keyscan -T 10 gitee.com >> ~/.ssh/known_hosts 2>/dev/null
|
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
|
bash scripts/sync-static.sh git@gitee.com:whyour/qinglong-static.git "$STATIC_BRANCH" "$STATIC_REF_TYPE"
|
||||||
cd static-mirror
|
|
||||||
git remote add gitee git@gitee.com:whyour/qinglong-static.git 2>/dev/null
|
|
||||||
|
|
||||||
if git push --force --all gitee 2>&1; then
|
sync-existing-static:
|
||||||
echo "::notice::Gitee static push succeeded"
|
if: ${{ github.event_name == 'workflow_dispatch' && inputs.static_sync_only }}
|
||||||
else
|
runs-on: ubuntu-latest
|
||||||
echo "::warning::Push failed, trying to create repo via API..."
|
strategy:
|
||||||
curl -sS --connect-timeout 30 --max-time 60 \
|
fail-fast: false
|
||||||
-X POST "https://gitee.com/api/v5/user/repos" \
|
matrix:
|
||||||
-H "Content-Type: application/json" \
|
mirror: [gitlab, gitee]
|
||||||
-d '{"name":"qinglong-static","private":"false"}' \
|
concurrency:
|
||||||
"?access_token=$GITEE_TOKEN" 2>/dev/null
|
group: static-${{ matrix.mirror }}-${{ inputs.static_ref }}
|
||||||
git push --force --all gitee 2>&1 && echo "::notice::Gitee static push succeeded after repo creation" || echo "::warning::Gitee static push failed after retry"
|
cancel-in-progress: false
|
||||||
fi
|
steps:
|
||||||
git push --force --tags gitee 2>&1 || echo "::warning::Gitee static tags push failed"
|
- 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:
|
build-alpine:
|
||||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ execFileSync(
|
|||||||
],
|
],
|
||||||
{ stdio: 'inherit' },
|
{ stdio: 'inherit' },
|
||||||
);
|
);
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(root, 'src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||||
|
path.join(root, 'dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||||
|
);
|
||||||
require('esbuild').buildSync({
|
require('esbuild').buildSync({
|
||||||
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
entryPoints: [path.join(root, 'src/shared/cli/commander.ts')],
|
||||||
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
outfile: path.join(root, 'dist/shared/cli/commander.js'),
|
||||||
@@ -45,7 +49,9 @@ const remote = require('esbuild').buildSync({
|
|||||||
metafile: true,
|
metafile: true,
|
||||||
});
|
});
|
||||||
for (const input of Object.keys(remote.metafile.inputs)) {
|
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(
|
throw new Error(
|
||||||
'Local implementation leaked into remote npm bundle: ' + input,
|
'Local implementation leaked into remote npm bundle: ' + input,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,33 @@ import { checkedProcess } from '../runtime/process';
|
|||||||
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
import { operationSignal, withoutCancellation } from '../runtime/cancellation';
|
||||||
import { installPanelDependencies, startPanel, stopPanel } from './operator';
|
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<unknown>;
|
||||||
|
waitForHealth(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
timeout?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void>;
|
||||||
|
withUpgradeLock<T>(tmp: string, operation: () => Promise<T>): Promise<T>;
|
||||||
|
stageUpgrade(options: {
|
||||||
|
root: string;
|
||||||
|
tmp: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
mirror: 'github' | 'gitee';
|
||||||
|
signal?: AbortSignal;
|
||||||
|
run(program: string, args: string[], capture?: boolean): Promise<unknown>;
|
||||||
|
install(source: string): Promise<void>;
|
||||||
|
}): Promise<{ source: string; static: string }>;
|
||||||
|
selectedUpgrade(
|
||||||
|
tmp: string,
|
||||||
|
branch: string,
|
||||||
|
): Promise<{ source: string; static: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export function releaseBranch(context: LocalContext): string {
|
export function releaseBranch(context: LocalContext): string {
|
||||||
return ['develop', 'debian', 'debian-dev'].includes(
|
return artifacts.releaseBranch(context.env);
|
||||||
context.env.QL_BRANCH ?? '',
|
|
||||||
)
|
|
||||||
? context.env.QL_BRANCH!
|
|
||||||
: 'master';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateTree(
|
async function validateTree(
|
||||||
@@ -48,134 +69,27 @@ export async function stageUpgrade(
|
|||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
mirror: 'github' | 'gitee',
|
mirror: 'github' | 'gitee',
|
||||||
): Promise<{ source: string; static: string }> {
|
): Promise<{ source: string; static: string }> {
|
||||||
await fs.mkdir(context.paths.dir_tmp!, { recursive: true });
|
return artifacts.withUpgradeLock(context.paths.dir_tmp!, () =>
|
||||||
const workspace = await fs.mkdtemp(
|
artifacts.stageUpgrade({
|
||||||
path.join(context.paths.dir_tmp!, 'upgrade-'),
|
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(
|
async function selectedUpgrade(
|
||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
): Promise<{ source: string; static: string }> {
|
): Promise<{ source: string; static: string }> {
|
||||||
const branch = releaseBranch(context);
|
return artifacts.selectedUpgrade(
|
||||||
const fallback = {
|
context.paths.dir_tmp!,
|
||||||
source: path.join(context.paths.dir_tmp!, `qinglong-${branch}`),
|
releaseBranch(context),
|
||||||
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'),
|
|
||||||
);
|
);
|
||||||
if (ready?.source !== selected.source || ready?.static !== selected.static)
|
|
||||||
throw new Error(translate(context.env, '暂存升级就绪记录与目录不匹配。'));
|
|
||||||
return selected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReloadLifecycle {
|
export interface ReloadLifecycle {
|
||||||
@@ -345,8 +259,7 @@ export async function reloadPanel(
|
|||||||
const staticRoot = selected.static;
|
const staticRoot = selected.static;
|
||||||
await validateTree(source, context.env);
|
await validateTree(source, context.env);
|
||||||
await validateTree(staticRoot, context.env);
|
await validateTree(staticRoot, context.env);
|
||||||
await fs.access(path.join(source, 'package.json'));
|
await artifacts.verifyUpgrade(source, staticRoot);
|
||||||
await fs.access(path.join(staticRoot, 'build/app.js'));
|
|
||||||
const entries = await fs.readdir(source);
|
const entries = await fs.readdir(source);
|
||||||
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']);
|
||||||
const relativeData = path.relative(context.root, context.data);
|
const relativeData = path.relative(context.root, context.data);
|
||||||
@@ -371,7 +284,16 @@ export async function reloadPanel(
|
|||||||
if (selection)
|
if (selection)
|
||||||
replacements.push({ source: selection.source, target: selection.target });
|
replacements.push({ source: selection.source, target: selection.target });
|
||||||
try {
|
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 {
|
} finally {
|
||||||
if (selection)
|
if (selection)
|
||||||
await fs.rm(selection.directory, { recursive: true, force: true });
|
await fs.rm(selection.directory, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -15,8 +15,17 @@ const {
|
|||||||
reloadPanel,
|
reloadPanel,
|
||||||
} = require('../../dist/internal/maintenance/upgrade');
|
} = require('../../dist/internal/maintenance/upgrade');
|
||||||
const { parse } = require('../helpers/commands.cjs');
|
const { parse } = require('../helpers/commands.cjs');
|
||||||
|
const {
|
||||||
|
writeManifest,
|
||||||
|
sourceCommit,
|
||||||
|
} = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||||
|
|
||||||
async function fixture(t) {
|
async function fixture(t) {
|
||||||
|
t.mock.method(
|
||||||
|
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||||
|
'waitForHealth',
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
const root = await fs.realpath(
|
const root = await fs.realpath(
|
||||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-operator-')),
|
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.CALLS = path.join(ctx.root, 'ports.jsonl');
|
||||||
ctx.env.BACK_PORT = '5600';
|
ctx.env.BACK_PORT = '5600';
|
||||||
ctx.env.GRPC_PORT = '5400';
|
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);
|
await startPanel(ctx);
|
||||||
ctx.env.QlPort = '5799';
|
ctx.env.QlPort = '5799';
|
||||||
ctx.env.QlGrpcPort = '5599';
|
ctx.env.QlGrpcPort = '5599';
|
||||||
await startPanel(ctx);
|
await startPanel(ctx);
|
||||||
assert.deepEqual((await fs.readFile(ctx.env.CALLS, 'utf8')).trim().split('\n').map(JSON.parse), [
|
assert.deepEqual(
|
||||||
['5700', '5500'], ['5700', '5500'], ['5799', '5599'], ['5799', '5599'],
|
(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.BACK_PORT, '5600');
|
||||||
assert.equal(ctx.env.GRPC_PORT, '5400');
|
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 payloads = path.join(ctx.root, 'payloads');
|
||||||
const archives = path.join(ctx.root, 'archives');
|
const archives = path.join(ctx.root, 'archives');
|
||||||
await fs.mkdir(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'), {
|
await fs.mkdir(path.join(payloads, 'qinglong-static-master/build'), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
const manifest = '{"name":"fixture"}';
|
const manifest = '{"name":"fixture"}';
|
||||||
await fs.writeFile(path.join(ctx.root, 'package.json'), manifest);
|
await fs.writeFile(path.join(ctx.root, 'package.json'), manifest);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(payloads, 'qinglong-master/package.json'),
|
path.join(payloads, `qinglong-${sourceCommit}/package.json`),
|
||||||
manifest,
|
manifest,
|
||||||
);
|
);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(payloads, 'qinglong-static-master/build/app.js'),
|
path.join(payloads, 'qinglong-static-master/build/app.js'),
|
||||||
'console.log("fixture")',
|
'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'])
|
for (const repo of ['qinglong', 'qinglong-static'])
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'/usr/bin/zip',
|
'/usr/bin/zip',
|
||||||
['-qry', path.join(archives, `${repo}.zip`), `${repo}-master`],
|
[
|
||||||
|
'-qry',
|
||||||
|
path.join(archives, `${repo}.zip`),
|
||||||
|
`${repo}-${repo === 'qinglong' ? sourceCommit : 'master'}`,
|
||||||
|
],
|
||||||
{ cwd: payloads },
|
{ cwd: payloads },
|
||||||
);
|
);
|
||||||
ctx.env.ARCHIVES = archives;
|
ctx.env.ARCHIVES = archives;
|
||||||
@@ -225,18 +264,6 @@ test('upgrade staging extracts both archives before publishing readiness and rej
|
|||||||
'curl',
|
'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);',
|
'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');
|
const staged = await stageUpgrade(ctx, 'github');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await fs.readFile(path.join(staged.source, 'package.json'), 'utf8'),
|
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',
|
'utf8',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
staged,
|
{ ...staged, sourceCommit, dependenciesChanged: false },
|
||||||
);
|
);
|
||||||
await fs.symlink(
|
await fs.symlink(
|
||||||
'../../outside',
|
'../../outside',
|
||||||
path.join(payloads, 'qinglong-master/escape'),
|
path.join(payloads, `qinglong-${sourceCommit}/escape`),
|
||||||
);
|
);
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'/usr/bin/zip',
|
'/usr/bin/zip',
|
||||||
['-qry', path.join(archives, 'qinglong.zip'), 'qinglong-master'],
|
['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`],
|
||||||
{ cwd: payloads },
|
{ cwd: payloads },
|
||||||
);
|
);
|
||||||
await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/);
|
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'),
|
reloadPanel(ctx, 'system'),
|
||||||
(error) =>
|
(error) =>
|
||||||
error.code === 'ENOENT' &&
|
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(staticRoot, 'build/app.js'), '');
|
||||||
await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n');
|
await fs.writeFile(path.join(source, '.env'), 'PORT=9999\nREPLACE_ME=true\n');
|
||||||
|
writeManifest(source, staticRoot);
|
||||||
const target = path.join(ctx.root, '.env');
|
const target = path.join(ctx.root, '.env');
|
||||||
const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n';
|
const original = 'PORT=5700\nPRIVATE_VALUE=preserved\n';
|
||||||
await fs.writeFile(target, original, { mode: 0o600 });
|
await fs.writeFile(target, original, { mode: 0o600 });
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ const os = require('node:os');
|
|||||||
const { createContext } = require('../../dist/internal/runtime/context');
|
const { createContext } = require('../../dist/internal/runtime/context');
|
||||||
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
|
||||||
const operator = require('../../dist/internal/maintenance/operator');
|
const operator = require('../../dist/internal/maintenance/operator');
|
||||||
|
const { writeManifest } = require('../../../test/helpers/upgrade-fixture.cjs');
|
||||||
const selectedLoader =
|
const selectedLoader =
|
||||||
'// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n';
|
'// QL_CLI_ROOT dist/local/entrypoints.js dist/local/cronEntrypoint.js\n';
|
||||||
|
|
||||||
async function fixture(t) {
|
async function fixture(t) {
|
||||||
|
t.mock.method(
|
||||||
|
require('../../dist/internal/maintenance/upgradeArtifacts.cjs'),
|
||||||
|
'waitForHealth',
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
const base = await fs.mkdtemp(
|
const base = await fs.mkdtemp(
|
||||||
path.join(os.tmpdir(), 'ql-upgrade-selection-'),
|
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(incoming, '// original released loader');
|
||||||
await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old');
|
await fs.writeFile(path.join(ctx.paths.dir_static, 'build/app.js'), 'old');
|
||||||
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new');
|
await fs.writeFile(path.join(staticRoot, 'build/app.js'), 'new');
|
||||||
|
writeManifest(source, staticRoot);
|
||||||
return { ctx, source, staticRoot, loader, incoming };
|
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(stopped, false);
|
||||||
assert.equal(await fs.readFile(f.loader, 'utf8'), 'unrecognized');
|
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"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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[@]}"
|
||||||
@@ -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[@]}"
|
||||||
+30
-81
@@ -283,98 +283,44 @@ usage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload_qinglong() {
|
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
|
sleep 3
|
||||||
delete_pm2
|
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
|
if [[ "$reload_target" == 'data' ]]; then
|
||||||
rm -rf ${dir_data}/*
|
rm -rf "${dir_data:?}"/*
|
||||||
mv -f ${dir_tmp}/data/* ${dir_data}/
|
mv -f "$dir_tmp"/data/* "$dir_data"/ || return $?
|
||||||
fi
|
fi
|
||||||
echo -e "[reload_qinglong] starting Triggered at $(date)" >>${dir_log}/reload.log
|
|
||||||
reload_pm2
|
reload_pm2
|
||||||
echo -e "[reload_qinglong] started Triggered at $(date)\n" >>${dir_log}/reload.log
|
|
||||||
}
|
}
|
||||||
|
|
||||||
## 更新 qinglong
|
## 更新 qinglong
|
||||||
update_qinglong() {
|
update_qinglong() {
|
||||||
rm -rf ${dir_tmp}/*
|
|
||||||
local mirror="gitee"
|
local mirror="gitee"
|
||||||
local downloadQLUrl="https://gitee.com/whyour/qinglong/repository/archive"
|
if curl --fail --silent --noproxy "*" --max-time 2 --head "https://github.com" >/dev/null; then
|
||||||
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
|
|
||||||
mirror="github"
|
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
|
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() {
|
# Preserve the updater's status through both tee and the final timing output.
|
||||||
wget -cqO "${dir_tmp}/static.zip" "${downloadStaticUrl}/${primary_branch}.zip"
|
run_update_logged() (
|
||||||
exit_status=$?
|
set -o pipefail
|
||||||
|
if [[ "$real_time" == 'true' ]]; then
|
||||||
if [[ $exit_status -eq 0 ]]; then
|
"$@"
|
||||||
t '更新青龙静态资源成功...\n'
|
elif [[ "$no_tee" == 'true' ]]; then
|
||||||
unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp}
|
"$@" >>"$file_path" 2>&1
|
||||||
|
|
||||||
check_update_dep
|
|
||||||
else
|
else
|
||||||
t '更新青龙静态资源失败,请检查网络...\n'
|
"$@" 2>&1 | tee -a "$file_path"
|
||||||
fi
|
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() {
|
diff_scripts() {
|
||||||
@@ -525,7 +471,7 @@ main() {
|
|||||||
|
|
||||||
if [[ "$p1" == "log" ]]; then
|
if [[ "$p1" == "log" ]]; then
|
||||||
show_service_logs "${@:2}"
|
show_service_logs "${@:2}"
|
||||||
return $?
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local log_dir="${p1}"
|
local log_dir="${p1}"
|
||||||
@@ -554,14 +500,17 @@ main() {
|
|||||||
|
|
||||||
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
[[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp"
|
||||||
|
|
||||||
|
local command_status=0
|
||||||
case $p1 in
|
case $p1 in
|
||||||
update)
|
update)
|
||||||
fix_config
|
fix_config
|
||||||
local needRestart=${p2:-"true"}
|
local needRestart=${p2:-"true"}
|
||||||
eval update_qinglong $cmd
|
run_update_logged update_qinglong
|
||||||
|
command_status=$?
|
||||||
;;
|
;;
|
||||||
reload)
|
reload)
|
||||||
eval reload_qinglong "$p2" $cmd
|
run_update_logged reload_qinglong "$p2"
|
||||||
|
command_status=$?
|
||||||
;;
|
;;
|
||||||
extra)
|
extra)
|
||||||
eval run_extra_shell $cmd
|
eval run_extra_shell $cmd
|
||||||
@@ -620,9 +569,9 @@ main() {
|
|||||||
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then
|
||||||
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒 " $cmd
|
eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒 " $cmd
|
||||||
fi
|
fi
|
||||||
|
return "$command_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
import_config "$@"
|
import_config "$@"
|
||||||
main "$@"
|
main "$@"
|
||||||
|
exit $?
|
||||||
exit 0
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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', '<script src="umi.js"></script>');
|
||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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\(/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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), []);
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
'<div id="root"></div>',
|
||||||
|
);
|
||||||
|
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 };
|
||||||
Reference in New Issue
Block a user