mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 06:58:52 +08:00
fix: validate ARMv6 upgrades and publish rolling development bundles
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
|||||||
- "cli/src/internal/maintenance/platformBundle.cjs"
|
- "cli/src/internal/maintenance/platformBundle.cjs"
|
||||||
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
- "cli/src/internal/maintenance/upgradePrestart.cjs"
|
||||||
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
- "cli/src/internal/maintenance/panelUpgrade.cjs"
|
||||||
|
- "cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||||
- "!.github/workflows/cli-package.yml"
|
- "!.github/workflows/cli-package.yml"
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
@@ -19,10 +20,6 @@ on:
|
|||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_development_bundles:
|
|
||||||
description: Develop only - build and publish shared develop/debian-dev upgrade packages
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
static_sync_only:
|
static_sync_only:
|
||||||
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
description: Synchronize an existing static snapshot to GitLab and verify Gitee
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -42,10 +39,10 @@ permissions:
|
|||||||
|
|
||||||
# Keep master image tags and their bundle indexes in one serialized release.
|
# Keep master image tags and their bundle indexes in one serialized release.
|
||||||
# A rerun may intentionally replace a version, including from the same commit.
|
# A rerun may intentionally replace a version, including from the same commit.
|
||||||
# Manual development bundle releases are serialized independently of master.
|
# Develop image builds and their shared bundle index serialize separately.
|
||||||
# Ordinary develop pushes and Git-tag builds remain independent.
|
# Git-tag builds and static synchronization remain independent.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles && 'develop-bundles') || format('{0}-{1}', github.run_id, github.run_attempt) }}
|
group: version-images-${{ github.ref == 'refs/heads/master' && 'master' || (github.ref == 'refs/heads/develop' && !inputs.static_sync_only && 'develop') || format('{0}-{1}', github.run_id, github.run_attempt) }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -116,14 +113,19 @@ jobs:
|
|||||||
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 }}
|
GITHUB_REF_TYPE: ${{ github.ref_type }}
|
||||||
|
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
bash scripts/publish-static.sh "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||||
|
bash scripts/publish-static.sh "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_REF_TYPE"
|
||||||
|
|
||||||
- name: publish verified upgrade download sizes
|
- name: publish verified upgrade download sizes
|
||||||
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 }}
|
||||||
run: node scripts/publish-upgrade-info.cjs "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
STATIC_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
: "${STATIC_TOKEN:?Missing static repository token}"
|
||||||
|
node scripts/publish-upgrade-info.cjs "https://x-access-token:${STATIC_TOKEN}@${GITHUB_REPO}.git" "$GITHUB_BRANCH" "$GITHUB_SHA"
|
||||||
|
|
||||||
static_gitlab:
|
static_gitlab:
|
||||||
needs: build-static
|
needs: build-static
|
||||||
@@ -657,8 +659,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
build-upgrade-bundles:
|
build-upgrade-bundles:
|
||||||
# Development packages are opt-in; both channels use this run's image digests.
|
# Both channels publish packages from this run's immutable image digests.
|
||||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
|
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||||
needs: [build-alpine, build-debian]
|
needs: [build-alpine, build-debian]
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
@@ -677,7 +679,6 @@ jobs:
|
|||||||
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
- { platform: linux/s390x, libc: musl, name: s390x-musl }
|
||||||
- { platform: linux/386, libc: musl, name: ia32-musl }
|
- { platform: linux/386, libc: musl, name: ia32-musl }
|
||||||
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
- { platform: linux/amd64, libc: glibc, name: x64-glibc }
|
||||||
- { platform: linux/arm/v6, libc: glibc, name: armv6-glibc }
|
|
||||||
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
- { platform: linux/arm/v7, libc: glibc, name: armv7-glibc }
|
||||||
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
- { platform: linux/arm64, libc: glibc, name: arm64-glibc }
|
||||||
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
- { platform: linux/ppc64le, libc: glibc, name: ppc64-glibc }
|
||||||
@@ -692,7 +693,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
ALPINE_DIGEST: ${{ needs.build-alpine.outputs.image-digest }}
|
||||||
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
DEBIAN_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
BUNDLE_LIBC: ${{ matrix.libc }}
|
BUNDLE_LIBC: ${{ matrix.libc }}
|
||||||
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
BUNDLE_PLATFORM: ${{ matrix.platform }}
|
||||||
run: |
|
run: |
|
||||||
@@ -701,18 +701,7 @@ jobs:
|
|||||||
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
if [ "$BUNDLE_LIBC" = glibc ]; then digest="$DEBIAN_DIGEST"; fi
|
||||||
test -n "$digest"
|
test -n "$digest"
|
||||||
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
image="ghcr.io/${GITHUB_REPOSITORY}@${digest}"
|
||||||
if [ "$BUNDLE_PLATFORM/$BUNDLE_LIBC" = linux/arm/v6/glibc ]; then
|
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
||||||
# This amd64 helper executes a genuine Raspbian ARMv6 userspace under
|
|
||||||
# ARM1176 QEMU. It is a CI validator, not a user runtime or bundle.
|
|
||||||
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
|
||||||
validator="ghcr.io/${GITHUB_REPOSITORY}:upgrade-armv6-glibc-validator-${GITHUB_SHA}"
|
|
||||||
mkdir -p output/validator
|
|
||||||
docker buildx build --platform linux/amd64 --build-arg "SOURCE_IMAGE=$image" --file docker/Dockerfile.armv6-glibc --target runtime --tag "$validator" --push --metadata-file output/validator/image.json .
|
|
||||||
validator_digest="$(jq -er '."containerimage.digest"' output/validator/image.json)"
|
|
||||||
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=${validator%:*}@${validator_digest}" --file docker/Dockerfile.upgrade-bundle-armv6-glibc --output type=local,dest=output/upgrade .
|
|
||||||
else
|
|
||||||
docker buildx build --platform "$BUNDLE_PLATFORM" --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output type=local,dest=output/upgrade .
|
|
||||||
fi
|
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
name: upgrade-${{ matrix.name }}-${{ github.sha }}
|
||||||
@@ -720,7 +709,7 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
publish-upgrade-bundles:
|
publish-upgrade-bundles:
|
||||||
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/develop' && inputs.build_development_bundles)) }}
|
if: ${{ !inputs.static_sync_only && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop') }}
|
||||||
needs: build-upgrade-bundles
|
needs: build-upgrade-bundles
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -747,5 +736,4 @@ jobs:
|
|||||||
- name: Publish complete packages before advancing the channel
|
- name: Publish complete packages before advancing the channel
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
QL_BUILD_DEVELOPMENT_BUNDLES: ${{ inputs.build_development_bundles }}
|
|
||||||
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
run: node scripts/publish-upgrade-bundles.cjs output/upgrade "$GITHUB_SHA" "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME"
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ import 'reflect-metadata';
|
|||||||
import dns from 'dns';
|
import dns from 'dns';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
|
||||||
|
// The first scheduler connection can outlive its short RPC deadline. Let the
|
||||||
|
// normal single-flight recovery finish within the supervised startup budget.
|
||||||
|
async function waitForUpgradeScheduler(
|
||||||
|
readiness: { recover(): Promise<boolean> },
|
||||||
|
timeoutMs: number,
|
||||||
|
retryMs = 250,
|
||||||
|
) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
do {
|
||||||
|
if (await readiness.recover()) return;
|
||||||
|
const remaining = deadline - Date.now();
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, Math.min(retryMs, remaining)),
|
||||||
|
);
|
||||||
|
} while (Date.now() < deadline);
|
||||||
|
throw new Error('Upgrade scheduler readiness failed');
|
||||||
|
}
|
||||||
|
|
||||||
async function probeUpgrade() {
|
async function probeUpgrade() {
|
||||||
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
if (!process.send || process.env.QL_UPGRADE_PROBE !== '1')
|
||||||
throw new Error('Upgrade probe requires its isolated supervisor');
|
throw new Error('Upgrade probe requires its isolated supervisor');
|
||||||
@@ -41,8 +60,16 @@ async function probeUpgrade() {
|
|||||||
config.grpcPort = await grpc.initialize();
|
config.grpcPort = await grpc.initialize();
|
||||||
const { default: cronClient } = await import('../schedule/client');
|
const { default: cronClient } = await import('../schedule/client');
|
||||||
cronClient.readiness.configure(async () => {});
|
cronClient.readiness.configure(async () => {});
|
||||||
if (!(await cronClient.readiness.recover()))
|
await waitForUpgradeScheduler(
|
||||||
throw new Error('Upgrade scheduler readiness failed');
|
cronClient.readiness,
|
||||||
|
process.arch === 'arm' &&
|
||||||
|
Number(
|
||||||
|
(process.config.variables as { arm_version?: string | number })
|
||||||
|
.arm_version,
|
||||||
|
) === 6
|
||||||
|
? 60000
|
||||||
|
: 10000,
|
||||||
|
);
|
||||||
const app = await createHttpApplication();
|
const app = await createHttpApplication();
|
||||||
loadExpress({ app });
|
loadExpress({ app });
|
||||||
const listener = await server.initialize(app, 0);
|
const listener = await server.initialize(app, 0);
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ fs.copyFileSync(
|
|||||||
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
path.join(root, 'src/internal/maintenance/panelUpgrade.cjs'),
|
||||||
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
path.join(root, 'dist/internal/maintenance/panelUpgrade.cjs'),
|
||||||
);
|
);
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(root, 'src/internal/maintenance/nodeRuntime.cjs'),
|
||||||
|
path.join(root, 'dist/internal/maintenance/nodeRuntime.cjs'),
|
||||||
|
);
|
||||||
// The operational CLI may live outside the panel and has no panel node_modules.
|
// The operational CLI may live outside the panel and has no panel node_modules.
|
||||||
require('esbuild').buildSync({
|
require('esbuild').buildSync({
|
||||||
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
entryPoints: [path.join(root, 'src/internal/maintenance/platformBundle.cjs')],
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Node 24's ARMv6 JS compiler tiers can trap on ARM1176. Keep WebAssembly
|
||||||
|
// enabled for fetch/undici; --jitless disables their HTTP parser as well.
|
||||||
|
const armv6ServerArgs = Object.freeze([
|
||||||
|
'--no-opt',
|
||||||
|
'--no-sparkplug',
|
||||||
|
'--no-maglev',
|
||||||
|
]);
|
||||||
|
function serverRuntimeArgs(runtime = process) {
|
||||||
|
return runtime.arch === 'arm' &&
|
||||||
|
Number(runtime.config.variables.arm_version) === 6 &&
|
||||||
|
Number(runtime.versions.node.split('.')[0]) >= 24
|
||||||
|
? [...armv6ServerArgs]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { serverRuntimeArgs, armv6ServerArgs };
|
||||||
|
if (require.main === module)
|
||||||
|
process.stdout.write(serverRuntimeArgs().join(' '));
|
||||||
@@ -7,6 +7,10 @@ import type { LocalContext } from '../runtime/context';
|
|||||||
import { atomicWrite } from '../runtime/files';
|
import { atomicWrite } from '../runtime/files';
|
||||||
import { runProcess, checkedProcess } from '../runtime/process';
|
import { runProcess, checkedProcess } from '../runtime/process';
|
||||||
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
import { findDirectBackendPids } from '../runtime/backendProcesses';
|
||||||
|
const { serverRuntimeArgs, armv6ServerArgs } = require('./nodeRuntime.cjs') as {
|
||||||
|
serverRuntimeArgs(): string[];
|
||||||
|
armv6ServerArgs: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function repairConfiguration(
|
export async function repairConfiguration(
|
||||||
context: LocalContext,
|
context: LocalContext,
|
||||||
@@ -117,7 +121,11 @@ async function stopDirectBackend(context: LocalContext): Promise<void> {
|
|||||||
for (const line of result.stdout.split('\n')) {
|
for (const line of result.stdout.split('\n')) {
|
||||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||||
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
if (!match || !/^node(?:\d+)?$/.test(path.basename(match[2]!))) continue;
|
||||||
if (match[3] !== entry) continue;
|
if (
|
||||||
|
match[3] !== entry &&
|
||||||
|
match[3] !== `${armv6ServerArgs.join(' ')} ${entry}`
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const pid = Number(match[1]);
|
const pid = Number(match[1]);
|
||||||
if (pid === process.pid) continue;
|
if (pid === process.pid) continue;
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +187,7 @@ export async function startPanel(
|
|||||||
0o600,
|
0o600,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const child = spawn(process.execPath, [entry], {
|
const child = spawn(process.execPath, [...serverRuntimeArgs(), entry], {
|
||||||
...options,
|
...options,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: ['ignore', log.fd, log.fd],
|
stdio: ['ignore', log.fd, log.fd],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const os = require('node:os');
|
|||||||
const { execFile } = require('node:child_process');
|
const { execFile } = require('node:child_process');
|
||||||
const { promisify } = require('node:util');
|
const { promisify } = require('node:util');
|
||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
|
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||||
const {
|
const {
|
||||||
prestartBackend,
|
prestartBackend,
|
||||||
dependencyFailure,
|
dependencyFailure,
|
||||||
@@ -211,10 +212,12 @@ async function smokeDependencies(source, signal) {
|
|||||||
`;
|
`;
|
||||||
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
|
// Match the server's normal runtime. Node 20.18's built-in HTTP parser needs
|
||||||
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
// WebAssembly, which --jitless disables; only archive workers need that flag.
|
||||||
await execute(process.execPath, ['-e', probe], {
|
await execute(process.execPath, [...serverRuntimeArgs(process), '-e', probe], {
|
||||||
cwd: source,
|
cwd: source,
|
||||||
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
env: { ...process.env, NODE_PATH: '', NODE_OPTIONS: '' },
|
||||||
timeout: 30000,
|
// ARM1176 devices need the same startup allowance as the backend probe.
|
||||||
|
// Keep this bounded and cancellable without forcing an unnecessary repair.
|
||||||
|
timeout: nodeRuntimeArgs().length ? 300000 : 30000,
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const path = require('node:path');
|
|||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
|
const { serverRuntimeArgs } = require('./nodeRuntime.cjs');
|
||||||
|
|
||||||
function probeLimits(target) {
|
function probeLimits(target) {
|
||||||
const small = target.arch === 'armv6';
|
const small = target.arch === 'armv6';
|
||||||
@@ -122,7 +123,7 @@ async function prestartBackend(
|
|||||||
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
// one backend process. Keep normal V8 mode for Node 20's HTTP/WASM support.
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[`--max-old-space-size=${limits.heapMb}`, entry],
|
[...serverRuntimeArgs(), `--max-old-space-size=${limits.heapMb}`, entry],
|
||||||
{
|
{
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
const { armv6ServerArgs } = require('../maintenance/nodeRuntime.cjs') as {
|
||||||
|
armv6ServerArgs: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
|
// Linux preserves argv boundaries in cmdline. Resolve relative entrypoints from
|
||||||
// each process's cwd, not from the operator's cwd or a broad ps substring match.
|
// each process's cwd, not from the operator's cwd or a broad ps substring match.
|
||||||
@@ -27,11 +30,18 @@ export async function findDirectBackendPids(
|
|||||||
);
|
);
|
||||||
const args = command.split('\0');
|
const args = command.split('\0');
|
||||||
if (args.at(-1) === '') args.pop();
|
if (args.at(-1) === '') args.pop();
|
||||||
// Both the legacy fallback and the TS fallback launch one script without
|
// Accept only the two launch shapes we own. Never treat eval, preload or
|
||||||
// Node flags or application arguments. Reject other command shapes.
|
// application arguments containing this path as a running backend.
|
||||||
if (args.length !== 2 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
const scriptIndex =
|
||||||
|
args.length === 2
|
||||||
|
? 1
|
||||||
|
: args.length === armv6ServerArgs.length + 2 &&
|
||||||
|
armv6ServerArgs.every((flag, i) => args[i + 1] === flag)
|
||||||
|
? args.length - 1
|
||||||
|
: -1;
|
||||||
|
if (scriptIndex < 0 || !/^node(?:\d+)?$/.test(path.basename(args[0]!)))
|
||||||
continue;
|
continue;
|
||||||
const script = args[1]!;
|
const script = args[scriptIndex]!;
|
||||||
if (!script) continue;
|
if (!script) continue;
|
||||||
const absolute = path.isAbsolute(script)
|
const absolute = path.isAbsolute(script)
|
||||||
? script
|
? script
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
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 vm = require('node:vm');
|
||||||
|
const { createRequire } = require('node:module');
|
||||||
|
const { createContext } = require('../../dist/internal/runtime/context');
|
||||||
|
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
|
||||||
|
const runtime = require('../../dist/internal/maintenance/nodeRuntime.cjs');
|
||||||
|
|
||||||
|
// Change only runtime selection; execute the compiled operator, actual child
|
||||||
|
// Node and process discovery, without pretending the host CPU is ARM.
|
||||||
|
async function operatorFor(arch, armVersion, node) {
|
||||||
|
const filename = require.resolve('../../dist/internal/maintenance/operator');
|
||||||
|
const localRequire = createRequire(filename);
|
||||||
|
const module = { exports: {} };
|
||||||
|
const flags = runtime.serverRuntimeArgs({
|
||||||
|
arch,
|
||||||
|
config: { variables: { arm_version: armVersion } },
|
||||||
|
versions: { node },
|
||||||
|
});
|
||||||
|
vm.runInNewContext(
|
||||||
|
await fs.readFile(filename, 'utf8'),
|
||||||
|
{
|
||||||
|
module,
|
||||||
|
exports: module.exports,
|
||||||
|
process,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
require(name) {
|
||||||
|
return name === './nodeRuntime.cjs'
|
||||||
|
? { ...runtime, serverRuntimeArgs: () => [...flags] }
|
||||||
|
: localRequire(name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ filename },
|
||||||
|
);
|
||||||
|
return { ...module.exports, flags };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [arch, arm, node] of [
|
||||||
|
['arm', 6, '24.18.1'],
|
||||||
|
['arm', 6, '20.18.1'],
|
||||||
|
['arm', 6, '22.20.0'],
|
||||||
|
['arm', 7, '24.18.1'],
|
||||||
|
['arm64', undefined, '24.18.1'],
|
||||||
|
['x64', undefined, '24.18.1'],
|
||||||
|
])
|
||||||
|
test(`CLI direct startup and stop use compatible arguments: ${arch}/${arm}/${node}`, async (t) => {
|
||||||
|
const operator = await operatorFor(arch, arm, node);
|
||||||
|
const root = await fs.realpath(
|
||||||
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-runtime-start-')),
|
||||||
|
);
|
||||||
|
const context = createContext(
|
||||||
|
{ root },
|
||||||
|
{ PATH: process.platform === 'linux' ? '' : '/usr/bin:/bin', QL_LANG: 'en' },
|
||||||
|
);
|
||||||
|
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||||
|
let pid;
|
||||||
|
t.after(async () => {
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
entry,
|
||||||
|
`require('fs').writeFileSync(${JSON.stringify(
|
||||||
|
path.join(root, 'runtime.json'),
|
||||||
|
)}, JSON.stringify({args:process.execArgv,pid:process.pid}));setInterval(()=>{},1000);`,
|
||||||
|
);
|
||||||
|
const started = await operator.startPanel(context);
|
||||||
|
pid = started.pid;
|
||||||
|
const report = JSON.parse(
|
||||||
|
await fs.readFile(path.join(root, 'runtime.json')),
|
||||||
|
);
|
||||||
|
assert.equal(started.manager, 'node');
|
||||||
|
assert.deepEqual(report.args, operator.flags);
|
||||||
|
assert.equal(report.pid, pid);
|
||||||
|
await operator.stopPanel(context);
|
||||||
|
for (let n = 0; n < 100; n++) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ESRCH') {
|
||||||
|
pid = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
pid,
|
||||||
|
undefined,
|
||||||
|
'the exact backend must stop even with Node flags',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ARMv6 CLI replacement stops flagged new backend before restoring old backend and data',
|
||||||
|
{
|
||||||
|
skip: process.platform !== 'linux',
|
||||||
|
timeout: 20000,
|
||||||
|
},
|
||||||
|
async (t) => {
|
||||||
|
const operator = await operatorFor('arm', 6, '24.18.1');
|
||||||
|
const root = await fs.realpath(
|
||||||
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-arm-rollback-')),
|
||||||
|
);
|
||||||
|
const context = createContext({ root }, { PATH: '', QL_LANG: 'en' });
|
||||||
|
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||||
|
const incoming = path.join(root, 'incoming.js'),
|
||||||
|
journal = path.join(root, 'journal.jsonl');
|
||||||
|
const record = (version) =>
|
||||||
|
`const fs=require('fs');fs.appendFileSync(${JSON.stringify(
|
||||||
|
journal,
|
||||||
|
)},JSON.stringify({version:${JSON.stringify(
|
||||||
|
version,
|
||||||
|
)},pid:process.pid,args:process.execArgv})+'\\n');setInterval(()=>{},1000);`;
|
||||||
|
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||||
|
await fs.mkdir(context.data, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, '.env'), 'KEEP=custom\n', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
await fs.writeFile(path.join(context.data, 'keep'), 'old data');
|
||||||
|
await fs.writeFile(entry, record('old'));
|
||||||
|
await fs.writeFile(incoming, record('new'));
|
||||||
|
const pids = [];
|
||||||
|
t.after(async () => {
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
pids.push((await operator.startPanel(context)).pid);
|
||||||
|
await assert.rejects(
|
||||||
|
replaceAndReload(context, [{ source: incoming, target: entry }], {
|
||||||
|
stop: (ctx) => operator.stopPanel(ctx),
|
||||||
|
start: async (ctx, recover) => {
|
||||||
|
const result = await operator.startPanel(ctx);
|
||||||
|
pids.push(result.pid);
|
||||||
|
if (!recover) throw new Error('injected new-version health failure');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/injected new-version health failure/,
|
||||||
|
);
|
||||||
|
const launches = (await fs.readFile(journal, 'utf8'))
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map(JSON.parse);
|
||||||
|
assert.deepEqual(
|
||||||
|
launches.map((x) => x.version),
|
||||||
|
['old', 'new', 'old'],
|
||||||
|
);
|
||||||
|
for (const row of launches) assert.deepEqual(row.args, operator.flags);
|
||||||
|
for (const row of launches.slice(0, -1))
|
||||||
|
assert.throws(() => process.kill(row.pid, 0), { code: 'ESRCH' });
|
||||||
|
process.kill(launches.at(-1).pid, 0);
|
||||||
|
assert.equal(
|
||||||
|
await fs.readFile(path.join(root, '.env'), 'utf8'),
|
||||||
|
'KEEP=custom\n',
|
||||||
|
);
|
||||||
|
assert.equal((await fs.stat(path.join(root, '.env'))).mode & 0o777, 0o600);
|
||||||
|
assert.equal(
|
||||||
|
await fs.readFile(path.join(context.data, 'keep'), 'utf8'),
|
||||||
|
'old data',
|
||||||
|
);
|
||||||
|
assert.equal(await fs.readFile(entry, 'utf8'), record('old'));
|
||||||
|
assert.ok(
|
||||||
|
!(await fs.readdir(path.dirname(entry))).some((x) =>
|
||||||
|
x.includes('ql-backup'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await operator.stopPanel(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,7 +3,46 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('node:fs/promises');
|
const fs = require('node:fs/promises');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const { findDirectBackendPids } = require('../../dist/internal/runtime/backendProcesses');
|
const {
|
||||||
|
findDirectBackendPids,
|
||||||
|
} = require('../../dist/internal/runtime/backendProcesses');
|
||||||
|
|
||||||
|
test('backend discovery accepts only owned compatibility flags and the exact installation entry', async (t) => {
|
||||||
|
const root = await fs.realpath(
|
||||||
|
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-flagged-proc-')),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
|
const proc = path.join(root, 'proc'),
|
||||||
|
panel = path.join(root, 'panel with spaces');
|
||||||
|
const other = path.join(root, 'other/static/build/app.js');
|
||||||
|
const entry = path.join(panel, 'static/build/app.js');
|
||||||
|
for (const file of [entry, other]) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await fs.writeFile(file, '');
|
||||||
|
}
|
||||||
|
const flags = ['--no-opt', '--no-sparkplug', '--no-maglev'];
|
||||||
|
const cases = [
|
||||||
|
[['/usr/bin/node', ...flags, entry], true],
|
||||||
|
[['node24', ...flags, 'static/build/app.js'], true],
|
||||||
|
[['node', ...flags, other], false],
|
||||||
|
[['node', '-e', entry], false],
|
||||||
|
[['node', '-r', entry], false],
|
||||||
|
[['node', ...flags, entry, 'argument'], false],
|
||||||
|
[['node', '--no-opt', entry], false],
|
||||||
|
[['bash', ...flags, entry], false],
|
||||||
|
[['node', ...flags, '-e', entry], false],
|
||||||
|
];
|
||||||
|
const expected = [];
|
||||||
|
for (const [i, [args, matches]] of cases.entries()) {
|
||||||
|
const pid = 910001 + i,
|
||||||
|
directory = path.join(proc, String(pid));
|
||||||
|
await fs.mkdir(directory, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(directory, 'cmdline'), args.join('\0') + '\0');
|
||||||
|
await fs.symlink(panel, path.join(directory, 'cwd'));
|
||||||
|
if (matches) expected.push(pid);
|
||||||
|
}
|
||||||
|
assert.deepEqual((await findDirectBackendPids(entry, proc)).sort(), expected);
|
||||||
|
});
|
||||||
|
|
||||||
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
|
test('Linux backend discovery scopes legacy relative and absolute commands to one installation', async (t) => {
|
||||||
const root = await fs.realpath(
|
const root = await fs.realpath(
|
||||||
@@ -64,7 +103,10 @@ test(
|
|||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const { once } = require('node:events');
|
const { once } = require('node:events');
|
||||||
const { createContext } = require('../../dist/internal/runtime/context');
|
const { createContext } = require('../../dist/internal/runtime/context');
|
||||||
const { stopPanel, startPanel } = require('../../dist/internal/maintenance/operator');
|
const {
|
||||||
|
stopPanel,
|
||||||
|
startPanel,
|
||||||
|
} = require('../../dist/internal/maintenance/operator');
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-live-proc-'));
|
||||||
const children = [];
|
const children = [];
|
||||||
let fallbackPid;
|
let fallbackPid;
|
||||||
@@ -223,7 +265,9 @@ for (const language of ['zh', 'en', 'unsupported']) {
|
|||||||
);
|
);
|
||||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||||
const { createContext } = require('../../dist/internal/runtime/context');
|
const { createContext } = require('../../dist/internal/runtime/context');
|
||||||
const { startPanel } = require('../../dist/internal/maintenance/operator');
|
const {
|
||||||
|
startPanel,
|
||||||
|
} = require('../../dist/internal/maintenance/operator');
|
||||||
const context = createContext({ root }, { PATH: '', QL_LANG: language });
|
const context = createContext({ root }, { PATH: '', QL_LANG: language });
|
||||||
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
const entry = path.join(context.paths.dir_static, 'build/app.js');
|
||||||
await fs.mkdir(path.dirname(entry), { recursive: true });
|
await fs.mkdir(path.dirname(entry), { recursive: true });
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
# ARMv7 is a source of application files only. None of its system libraries or
|
|
||||||
# Node binaries are executed or copied into the ARMv6 runtime.
|
|
||||||
ARG SOURCE_IMAGE
|
|
||||||
FROM --platform=linux/arm/v7 ${SOURCE_IMAGE} AS source
|
|
||||||
|
|
||||||
# PRoot keeps ARM1176 execution isolated, without installing host binfmt handlers.
|
|
||||||
FROM --platform=linux/amd64 debian:bookworm-slim@sha256:7c7b2c966bc9ee8cedfeef67e0e279108992c77681fa595db4a9d65c06ccc587 AS runtime
|
|
||||||
COPY --from=source /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
|
||||||
RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources && \
|
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
debootstrap gpg gpgv ca-certificates curl qemu-user-static && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
# Debian's PRoot 5.1 predates faccessat2, which modern QEMU uses for guest
|
|
||||||
# access checks. Pin the upstream static tracer instead of misresolving paths.
|
|
||||||
RUN curl -fsSL https://github.com/proot-me/proot/releases/download/v5.5.0/proot -o /usr/local/bin/proot && \
|
|
||||||
echo 'db185300a9daabbce86aca7632d8c7fdc4249f6f153171954013b70c5f491ec0 /usr/local/bin/proot' | sha256sum -c - && \
|
|
||||||
chmod 755 /usr/local/bin/proot && proot --version
|
|
||||||
RUN curl -fsSL https://archive.raspbian.org/raspbian.public.key -o /tmp/raspbian.key && \
|
|
||||||
echo 'ca59cd4f2bcbc3a1d41ba6815a02a8dc5c175467a59bd87edeac458f4a5345de /tmp/raspbian.key' | sha256sum -c - && \
|
|
||||||
gpg --dearmor -o /usr/share/keyrings/raspbian.gpg /tmp/raspbian.key && \
|
|
||||||
debootstrap --foreign --arch=armhf --variant=minbase \
|
|
||||||
--keyring=/usr/share/keyrings/raspbian.gpg \
|
|
||||||
bullseye /armv6 https://archive.raspbian.org/raspbian
|
|
||||||
COPY docker/armv6-run /usr/local/bin/armv6-run
|
|
||||||
RUN chmod 755 /usr/local/bin/armv6-run && \
|
|
||||||
DEBOOTSTRAP_DIR=/debootstrap armv6-run /debootstrap/debootstrap --second-stage && \
|
|
||||||
cp /usr/share/keyrings/raspbian.gpg /armv6/usr/share/keyrings/raspbian.gpg && \
|
|
||||||
printf '%s\n' 'deb [signed-by=/usr/share/keyrings/raspbian.gpg] https://archive.raspbian.org/raspbian bullseye main' > /armv6/etc/apt/sources.list && \
|
|
||||||
mkdir -p /armv6/etc/ssl/certs && \
|
|
||||||
cp /etc/ssl/certs/ca-certificates.crt /armv6/etc/ssl/certs/ca-certificates.crt && \
|
|
||||||
printf '127.0.0.1 localhost\n::1 localhost\n' > /armv6/etc/hosts && \
|
|
||||||
printf '#!/bin/sh\nexit 101\n' > /armv6/usr/sbin/policy-rc.d && \
|
|
||||||
chmod 755 /armv6/usr/sbin/policy-rc.d && \
|
|
||||||
armv6-run /bin/sh -ec 'apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils python3 ca-certificates curl git nginx procps jq sudo; rm -rf /var/lib/apt/lists/*'
|
|
||||||
# This Node is a pinned CI/test prerequisite, never part of the upgrade archive.
|
|
||||||
# Build against the minimum supported Node, including its bundled C++ headers.
|
|
||||||
RUN curl -fsSL https://unofficial-builds.nodejs.org/download/release/v20.18.1/node-v20.18.1-linux-armv6l.tar.gz -o /tmp/node.tar.gz && \
|
|
||||||
echo 'd4343a3d0fe23ddbd785537271bb0b60435edd50f5fba5bc580134592d51f011 /tmp/node.tar.gz' | sha256sum -c - && \
|
|
||||||
tar -xzf /tmp/node.tar.gz -C /armv6/usr/local --strip-components=1 && rm /tmp/node.tar.gz
|
|
||||||
COPY --from=source /ql/ /armv6/ql/
|
|
||||||
# PM2 is JavaScript. Keep the ARMv6 Node/npm and the Raspbian system libraries.
|
|
||||||
COPY --from=source /usr/local/lib/node_modules/pm2/ /armv6/usr/local/lib/node_modules/pm2/
|
|
||||||
RUN ln -s ../lib/node_modules/pm2/bin/pm2 /armv6/usr/local/bin/pm2 && \
|
|
||||||
armv6-run /bin/bash -ec 'ulimit -c 0; cd "$(readlink -f /ql/node_modules/sqlite3)"; CFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" CXXFLAGS="-march=armv6 -mfpu=vfp -mfloat-abi=hard" npm_config_nodedir=/usr/local npm_config_build_from_source=true node --jitless -e '\''require("node:child_process").execFileSync(process.execPath,[require.resolve("@mapbox/node-pre-gyp/bin/node-pre-gyp"),"rebuild","--napi_build_version=6"],{stdio:"inherit"})'\''; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep -E "Tag_CPU_arch: v6($|K)"; readelf -A lib/binding/napi-v6-linux-arm/node_sqlite3.node | grep "Tag_ABI_VFP_args: VFP registers"; rm -rf build; node --jitless -e '\''const p=process; if(p.config.variables.arm_version!=="6" && p.config.variables.arm_version!==6) process.exit(1); console.log(JSON.stringify({node:p.version,arm:p.config.variables.arm_version,float:p.config.variables.arm_float_abi,glibc:p.report.getReport().header.glibcVersionRuntime}));'\'''
|
|
||||||
LABEL qinglong.qa.runtime="armv6-glibc-arm1176"
|
|
||||||
ENTRYPOINT ["/usr/local/bin/armv6-run"]
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# An immutable digest of the CI-only ARM1176 validator, built in the same run.
|
|
||||||
ARG BASE_IMAGE
|
|
||||||
FROM ${BASE_IMAGE} AS bundle
|
|
||||||
ARG BASE_IMAGE
|
|
||||||
ENV QL_BUNDLE_BASE_IMAGE=${BASE_IMAGE}
|
|
||||||
RUN ulimit -c 0; armv6-run /bin/bash -ec \
|
|
||||||
'node --jitless /ql/scripts/build-upgrade-bundle.cjs /ql /out; \
|
|
||||||
for archive in /out/*.tar.gz; do \
|
|
||||||
node /ql/shell/install-bundle.cjs --root /tmp/ql-bundle-offline-smoke --file "$archive" --install; \
|
|
||||||
rm -rf /tmp/ql-bundle-offline-smoke; \
|
|
||||||
done'
|
|
||||||
FROM scratch
|
|
||||||
COPY --from=bundle /armv6/out/ /
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -eu
|
|
||||||
export PROOT_NO_SECCOMP=1 QEMU_CPU=arm1176
|
|
||||||
# Bind only kernel interfaces; all application files and writable state remain
|
|
||||||
# inside this container's ARMv6 root. Child processes also pass through QEMU.
|
|
||||||
exec proot -0 -q '/usr/bin/qemu-arm-static -cpu arm1176' \
|
|
||||||
-r /armv6 -b /proc -b /dev -w / "$@"
|
|
||||||
@@ -8,15 +8,17 @@ CI 从同次构建的不可变镜像摘要导出完整 tar.gz,包含源码中
|
|||||||
|
|
||||||
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
面板下载任务与本地离线入口共用 `platformBundle.cjs`:平台匹配 → 归档安全检查 → 全文件/链接/权限和锁文件校验 → SQLite/Sequelize/cron-parser/gRPC/undici 加载及 SQLite 内存库读写 → 隔离后端预启动 → 发布就绪指针。预启动使用现有 Node、临时数据目录和 loopback 随机端口,运行真实数据库建表/迁移和事务读写、gRPC 服务,以及与正常启动共用的 HTTP 中间件、路由和 WebSocket 初始化,再通过 `/api/health` 确认双服务与目标版本、提交和后端摘要;不运行任务、订阅、通知、用户启动钩子和系统依赖安装。临时库不是用户数据库副本,因此实际应用后的健康检查和回退仍不可省略。点击重启时,接口只检查就绪记录并提交后台任务;完整文件与依赖复验由独立工作进程执行,复验通过后才停止旧服务,避免小设备的预检耗时超过 HTTP 请求期限。离线升级同步面板任务状态,并与在线任务互斥;中断准备时保留之前的可用就绪包。原有服务停止、文件备份、启动健康检查及失败恢复仍由共享升级引擎处理。
|
||||||
|
|
||||||
正式升级包跟随 master 同一次版本镜像构建,直接使用该次 Alpine/Debian 镜像的不可变 digest;develop 的普通提交和 Git tag 工作流不生成或发布完整包。开发包需要手动运行现有镜像工作流:选择 develop,并勾选 `build_development_bundles`(默认为 false,不能与 `static_sync_only` 同时启用)。也可执行:
|
正式升级包跟随 master 同一次版本镜像构建;develop 的普通提交也自动构建开发包。两者都直接使用该次 Alpine/Debian 镜像的不可变 digest。Git tag 工作流和 `static_sync_only` 不生成完整包。需要重建当前开发包时,可手动运行同一工作流:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
gh workflow run build-docker-image.yml --ref develop -f build_development_bundles=true
|
gh workflow run build-docker-image.yml --ref develop
|
||||||
```
|
```
|
||||||
|
|
||||||
手动运行会构建该次 develop 提交的两种镜像,并从输出的固定 digest 导出完整包,更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的手动发布串行执行,普通 develop CI 不受该队列影响。
|
开发包更新共用的 `upgrade-develop/latest.json`。`develop` 和 `debian-dev` 镜像都使用该索引,按实际 Node 架构和 musl/glibc 选择包,无需设置测试专用下载源。开发批次位于 `upgrade-build-develop-v<version>-<run-id>-<run-attempt>`,不会改写正式版本或 `upgrade-master`。同一开发渠道的镜像构建和包发布串行执行,与 master 独立。
|
||||||
|
|
||||||
开发和正式包都必须在 13 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。ARMv6 glibc 继续使用同次 Debian 应用源码及专用 ARM1176 构建和验证环境。
|
开发包只保留最新一个已验证批次:所有平台资产和渠道索引的大小、SHA256 校验成功后,删除其他名称匹配的开发构建 prerelease;不删除正式包、渠道索引或无法分类的旧 release。构建或上传失败不会删除上一套可用包。旧批次被删除后,已选中旧索引但尚未下载完的客户端可能需要重新发起下载;准备失败仍保留旧服务和数据。
|
||||||
|
|
||||||
|
开发和正式包都必须在 12 个平台完成归档、解包后复验及依赖试运行,并且全部上传资产的大小和 SHA256 复验通过后,才更新各自索引。平台范围与现有 Alpine/Debian 镜像一致:ARMv6 仅提供 musl 包,不提供 ARMv6 glibc 包。
|
||||||
|
|
||||||
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
版本入口为 GitHub prerelease `upgrade-v<version>` 下的 `latest.json`;在线正式渠道保留 `upgrade-master/latest.json`,兼容现有客户端。二者指向同一套完整包,指定版本与 latest 不重复生成产物。索引记录渠道、版本、源码提交、构建批次、准确字节数、SHA256,以及 `upgrade-build-v<version>-<run-id>-<run-attempt>` 下的固定下载 URL。相同版本的镜像 tag 重新发布时,完整包也重新生成,版本和正式渠道索引同步覆盖到新批次;同一提交重跑也允许产生新内容。已选中旧索引的下载继续使用旧批次资产,避免覆盖文件导致哈希不匹配。master 的镜像构建和完整包发布整条流水线串行执行,避免镜像 tag 与完整包被并发重发交错覆盖;旧工作流明确重跑时也允许同版本、同提交重新发布。过期源码提交不能推进对应渠道或版本索引;平台包上传失败时不切换索引。
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
|
|||||||
|
|
||||||
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
`--install` 拒绝非空目录,不覆盖既有配置。该包安装青龙及其 Node 生产依赖;首次启动仍需已有 Python、Bash、OpenSSL、PM2、nginx 等相应运行环境,不会在线安装或替换它们。手动启动脚本会在启动工作进程前完成首次 gRPC 证书准备,避免慢设备生成密钥耗尽工作进程的就绪期限;已有证书继续复用。服务启动和系统自启按原手动安装流程配置。
|
||||||
|
|
||||||
ARMv6 glibc 小设备(例如 Raspberry Pi 1/Zero 的 Raspbian/Raspberry Pi OS 32 位系统)选择 `linux-armv6-glibc.tar.gz`,以同一安装器执行上述离线命令。该包要求 glibc >= 2.31、ARMv6 硬浮点 Node >= 20.18.1,不适用于 ARMv5、软浮点 Node 或旧版 glibc。安装和升级均不需要 Docker,也不会下载或替换 Node。
|
ARMv6 小设备使用 `linux-armv6-musl.tar.gz`,要求已有兼容的 musl 系统及硬浮点 Node。ARMv6 glibc(例如 Raspbian/Raspberry Pi OS 32 位系统)不在平台包支持范围内,不能使用 ARMv7 或 musl 包替代;安装器仍会检查实际架构和 libc,拒绝不匹配的包。
|
||||||
|
|
||||||
## npm 面板包
|
## npm 面板包
|
||||||
|
|
||||||
@@ -87,9 +89,8 @@ master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,
|
|||||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||||
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
- 以 Node 实际运行架构、ARM 指令集和 libc 类型/版本选择包;不依据 Docker 架构别名猜测。
|
||||||
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
- glibc、musl 分包,并记录构建系统 libc 下限。系统库仍以目标设备加载测试作为最后检查。
|
||||||
- ARMv6 glibc 使用签名校验的 Raspbian bullseye(glibc 2.31)构建环境,以固定 SHA256 的 ARMv6 Node 20.18.1 头文件编译 SQLite,强制 `-march=armv6 -mfpu=vfp -mfloat-abi=hard`。CI 检查 ELF 的 ARMv6 和硬浮点标记,并在 QEMU ARM1176 下完成归档复验、SQLite 读写和空目录离线安装;验证辅助镜像按不可变摘要记录,不进入用户安装包。Debian ARMv7 镜像仅提供同次构建的应用文件,不复用其 Node、libc 或 SQLite 二进制。
|
|
||||||
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
- ARMv6 与 ARMv7 使用不同包名。ARMv6 在目标镜像中从源码编译 SQLite,禁止复用 generic `arm` 的 ARMv7 预编译件;构建需通过加载和内存库读写,失败则不发布渠道。
|
||||||
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,正常面板进程的启动参数不变。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
- ARMv6 的归档构建、在线更新工作进程及独立离线安装器使用 `--jitless`,避开 ARM1176 上 V8 的优化编译崩溃;保留现有 Node 二进制,Node 24 及以上的 ARMv6 面板进程通过共享 `nodeRuntime` 使用 `--no-opt --no-sparkplug --no-maglev`,其他架构和较低 Node 版本保持原启动参数。ARMv6 的 gRPC 工作进程首次启动及重建最多等待 2 分钟;升级后的整体健康检查最多等待 5 分钟,覆盖慢设备按顺序启动数据库、调度与 HTTP 工作进程的耗时;其他平台仍为 60 秒,超时或版本校验失败仍执行回退。依赖加载与后端预启动使用现有 Node 的正常模式,使 Node 20.18.1 的内置 HTTP/WebAssembly 组件保持可用。预启动只有一个后端进程,按顺序初始化数据库、gRPC、HTTP,不启动 PM2/cluster;ARMv6 V8 堆上限 128 MiB、期限 5 分钟,其他平台为 192 MiB/60 秒。堆上限不是 RSS 总内存上限,系统仍需为现有服务和 OpenSSL 留出内存。依赖修复的包管理器及编译使用单并发、低网络并发和有界时长;ARMv6 包管理器采用 `--jitless`、256 MiB 堆上限,并优先复用版本匹配的现有 Node 头文件与实际运行时编译配置;超时、中断会终止整个进程组并清理临时目录。
|
||||||
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
- tar 中允许 pnpm 的包内符号链接;拒绝绝对路径、越界路径、包外链接、硬链接、设备条目和经符号链接写入的条目。重启前再次校验依赖,暂存失败保留上一次可用的就绪包。
|
||||||
|
|
||||||
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
自托管镜像可设置 `QL_UPGRADE_INDEX_URL` 指向兼容索引;要求 HTTPS,测试时仅放行 loopback HTTP。索引和所有平台归档应部署到同一可访问源,避免依赖额外的 raw 域名。
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const isContainer = process.env.QL_CONTAINER === 'true';
|
const isContainer = process.env.QL_CONTAINER === 'true';
|
||||||
|
const runtimeArgs =
|
||||||
|
require('./cli/src/internal/maintenance/nodeRuntime.cjs').serverRuntimeArgs();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
@@ -18,6 +20,7 @@ module.exports = {
|
|||||||
// visible through `docker logs` before Winston is initialized.
|
// visible through `docker logs` before Winston is initialized.
|
||||||
error_file: isContainer ? '/proc/1/fd/2' : undefined,
|
error_file: isContainer ? '/proc/1/fd/2' : undefined,
|
||||||
script: 'static/build/app.js',
|
script: 'static/build/app.js',
|
||||||
|
...(runtimeArgs.length ? { node_args: runtimeArgs } : {}),
|
||||||
env: {
|
env: {
|
||||||
http_proxy: '',
|
http_proxy: '',
|
||||||
https_proxy: '',
|
https_proxy: '',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const platforms = [
|
|||||||
'linux-s390x-musl',
|
'linux-s390x-musl',
|
||||||
'linux-ia32-musl',
|
'linux-ia32-musl',
|
||||||
'linux-x64-glibc',
|
'linux-x64-glibc',
|
||||||
'linux-armv6-glibc',
|
|
||||||
'linux-armv7-glibc',
|
'linux-armv7-glibc',
|
||||||
'linux-arm64-glibc',
|
'linux-arm64-glibc',
|
||||||
'linux-ppc64-glibc',
|
'linux-ppc64-glibc',
|
||||||
@@ -61,20 +60,14 @@ function publishBundles({
|
|||||||
branch,
|
branch,
|
||||||
version,
|
version,
|
||||||
buildId,
|
buildId,
|
||||||
allowDevelopment = false,
|
|
||||||
gh = (...args) =>
|
gh = (...args) =>
|
||||||
execFileSync('gh', args, {
|
execFileSync('gh', args, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
}),
|
}),
|
||||||
}) {
|
}) {
|
||||||
if (
|
if (!['master', 'develop'].includes(branch))
|
||||||
branch !== 'master' &&
|
throw new Error('Only master or develop images publish bundles');
|
||||||
!(branch === 'develop' && allowDevelopment === true)
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
'Only master or explicitly requested development images publish bundles',
|
|
||||||
);
|
|
||||||
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
|
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(version || ''))
|
||||||
throw new Error('Invalid bundle version');
|
throw new Error('Invalid bundle version');
|
||||||
if (!/^\d+-\d+$/.test(buildId || ''))
|
if (!/^\d+-\d+$/.test(buildId || ''))
|
||||||
@@ -226,6 +219,45 @@ function publishBundles({
|
|||||||
'--clobber',
|
'--clobber',
|
||||||
);
|
);
|
||||||
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
gh('release', 'edit', ref, '--repo', repository, '--notes', notes);
|
||||||
|
if (
|
||||||
|
!matches(
|
||||||
|
lookup(ref)?.assets.find((a) => a.name === 'latest.json'),
|
||||||
|
path.join(directory, 'latest.json'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Published channel index verification failed');
|
||||||
|
}
|
||||||
|
// Only prune development releases after the complete new batch and its
|
||||||
|
// channel pointer are verified. Production versions and aliases are retained.
|
||||||
|
if (development && currentSource()) {
|
||||||
|
const pages = JSON.parse(
|
||||||
|
gh(
|
||||||
|
'api',
|
||||||
|
`repos/${repository}/releases?per_page=100`,
|
||||||
|
'--paginate',
|
||||||
|
'--slurp',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const release of pages.flat()) {
|
||||||
|
if (
|
||||||
|
release.tag_name !== tag &&
|
||||||
|
/^upgrade-build-develop-v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?-\d+-\d+$/.test(
|
||||||
|
release.tag_name,
|
||||||
|
) &&
|
||||||
|
release.prerelease === true &&
|
||||||
|
release.draft === false
|
||||||
|
) {
|
||||||
|
if (!currentSource()) break;
|
||||||
|
gh(
|
||||||
|
'release',
|
||||||
|
'delete',
|
||||||
|
release.tag_name,
|
||||||
|
'--repo',
|
||||||
|
repository,
|
||||||
|
'--yes',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -246,8 +278,5 @@ if (require.main === module) {
|
|||||||
branch,
|
branch,
|
||||||
version,
|
version,
|
||||||
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
|
buildId: `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
|
||||||
allowDevelopment:
|
|
||||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
|
|
||||||
process.env.QL_BUILD_DEVELOPMENT_BUNDLES === 'true',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ const requiredFiles = [
|
|||||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||||
'sample/config.sample.sh', 'sample/auth.sample.json',
|
'sample/config.sample.sh', 'sample/auth.sample.json',
|
||||||
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
||||||
'static/build/bootstrap/upgradeProbe.js', 'static/dist/index.html',
|
'static/build/bootstrap/upgradeProbe.js',
|
||||||
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade']
|
'static/dist/index.html',
|
||||||
|
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade', 'nodeRuntime']
|
||||||
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,19 @@ fi
|
|||||||
for requested_ref in "${refs[@]}"; do
|
for requested_ref in "${refs[@]}"; do
|
||||||
git check-ref-format "$requested_ref"
|
git check-ref-format "$requested_ref"
|
||||||
done
|
done
|
||||||
command -v timeout >/dev/null || { echo 'GNU timeout is required' >&2; exit 2; }
|
# Ubuntu 26.04's default timeout is from uutils. We require GNU's process-group
|
||||||
|
# termination so git transport children cannot keep the query pipes open.
|
||||||
|
timeout_command=''
|
||||||
|
for candidate in timeout gnutimeout gtimeout; do
|
||||||
|
if version=$("$candidate" --version 2>/dev/null) && [[ "$version" == *'(GNU coreutils)'* ]]; then
|
||||||
|
timeout_command=$candidate
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -z "$timeout_command" ]]; then
|
||||||
|
echo 'GNU timeout is required (timeout, gnutimeout or gtimeout)' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
if [[ -n "$notify_repo" ]]; then
|
if [[ -n "$notify_repo" ]]; then
|
||||||
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
if [[ ! "$notify_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||||
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
|
echo 'QL_MIRROR_NOTIFY_REPO must be a GitHub owner/repository' >&2
|
||||||
@@ -62,7 +74,7 @@ notification_command() {
|
|||||||
local budget=$((notification_deadline - SECONDS))
|
local budget=$((notification_deadline - SECONDS))
|
||||||
if (( budget <= 0 )); then return 1; fi
|
if (( budget <= 0 )); then return 1; fi
|
||||||
if (( budget > 10 )); then budget=10; fi
|
if (( budget > 10 )); then budget=10; fi
|
||||||
timeout --signal=KILL "${budget}s" "$@"
|
"$timeout_command" --signal=KILL "${budget}s" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
notify_static_mirror() {
|
notify_static_mirror() {
|
||||||
@@ -156,7 +168,7 @@ while (( SECONDS < wait_seconds )); do
|
|||||||
fi
|
fi
|
||||||
budget=$query_timeout
|
budget=$query_timeout
|
||||||
if (( budget > remaining )); then budget=$remaining; fi
|
if (( budget > remaining )); then budget=$remaining; fi
|
||||||
if ! output=$(timeout --signal=KILL "${budget}s" git ls-remote --refs \
|
if ! output=$("$timeout_command" --signal=KILL "${budget}s" git ls-remote --refs \
|
||||||
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
"${urls[$index]}" "${refs[@]}" | LC_ALL=C sort); then
|
||||||
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
|
echo "::warning::Could not read ${labels[$index]} refs on attempt $attempt; retrying"
|
||||||
ready=false
|
ready=false
|
||||||
|
|||||||
+15
-2
@@ -294,6 +294,18 @@ delete_pm2() {
|
|||||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load_node_runtime_args() {
|
||||||
|
ql_node_runtime_args=()
|
||||||
|
local runtime_args
|
||||||
|
local helper="$dir_root/cli/src/internal/maintenance/nodeRuntime.cjs"
|
||||||
|
if [[ -f "$helper" ]]; then
|
||||||
|
runtime_args=$(node "$helper") || return $?
|
||||||
|
if [[ -n "$runtime_args" ]]; then
|
||||||
|
read -r -a ql_node_runtime_args <<<"$runtime_args"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
reload_pm2() {
|
reload_pm2() {
|
||||||
cd "$dir_root" || return $?
|
cd "$dir_root" || return $?
|
||||||
restore_env_vars
|
restore_env_vars
|
||||||
@@ -305,6 +317,7 @@ reload_pm2() {
|
|||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
|
t '警告: PM2 启动失败 (退出码: %s),可能是由于硬件不兼容' "$exit_code"
|
||||||
t '正在尝试直接使用 Node.js 启动服务...'
|
t '正在尝试直接使用 Node.js 启动服务...'
|
||||||
|
load_node_runtime_args || return $?
|
||||||
|
|
||||||
# Kill any existing node processes for qinglong
|
# Kill any existing node processes for qinglong
|
||||||
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
pkill -f "node.*static/build/app.js" 2>/dev/null || true
|
||||||
@@ -312,9 +325,9 @@ reload_pm2() {
|
|||||||
# Start node directly in the background. In containers, keep application
|
# Start node directly in the background. In containers, keep application
|
||||||
# output on the container streams instead of duplicating Winston logs on disk.
|
# output on the container streams instead of duplicating Winston logs on disk.
|
||||||
if [[ "$QL_CONTAINER" == "true" ]]; then
|
if [[ "$QL_CONTAINER" == "true" ]]; then
|
||||||
nohup node static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >/proc/1/fd/1 2>/proc/1/fd/2 &
|
||||||
else
|
else
|
||||||
nohup node static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
nohup node "${ql_node_runtime_args[@]}" static/build/app.js >"$dir_log/qinglong.log" 2>&1 &
|
||||||
fi
|
fi
|
||||||
local node_pid=$!
|
local node_pid=$!
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ fix_config
|
|||||||
|
|
||||||
# Slow devices may need more than the worker's readiness window to generate
|
# Slow devices may need more than the worker's readiness window to generate
|
||||||
# first-boot certificates. Reuse the regular initializer before starting workers.
|
# first-boot certificates. Reuse the regular initializer before starting workers.
|
||||||
node <<'QL_PREPARE_GRPC_CERTS'
|
load_node_runtime_args
|
||||||
|
node "${ql_node_runtime_args[@]}" <<'QL_PREPARE_GRPC_CERTS'
|
||||||
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
|
require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcCerts.js'))
|
||||||
.initGrpcCerts()
|
.initGrpcCerts()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -63,7 +64,7 @@ require(require('node:path').join(process.env.QL_DIR, 'static/build/config/grpcC
|
|||||||
});
|
});
|
||||||
QL_PREPARE_GRPC_CERTS
|
QL_PREPARE_GRPC_CERTS
|
||||||
|
|
||||||
pm2 l &>/dev/null
|
pm2 l &>/dev/null || true
|
||||||
|
|
||||||
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
|
log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')"
|
||||||
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ test('mirror verification reads matching refs without changing either repository
|
|||||||
assert.equal(git('-C', source, 'status', '--porcelain'), '');
|
assert.equal(git('-C', source, 'status', '--porcelain'), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mirror verification selects GNU timeout when the default is another implementation', (t) => {
|
||||||
|
const { tmp, source, mirror } = fixture(t);
|
||||||
|
git('-C', source, 'push', mirror, 'develop');
|
||||||
|
const realTimeout = ['timeout', 'gnutimeout', 'gtimeout'].find((name) => {
|
||||||
|
const result = spawnSync(name, ['--version'], { encoding: 'utf8' });
|
||||||
|
return result.status === 0 && result.stdout.includes('(GNU coreutils)');
|
||||||
|
});
|
||||||
|
assert.ok(realTimeout, 'The mirror script requires GNU timeout');
|
||||||
|
const bin = path.join(tmp, 'bin');
|
||||||
|
fs.mkdirSync(bin);
|
||||||
|
fs.symlinkSync(execFileSync('which', [realTimeout], { encoding: 'utf8' }).trim(), path.join(bin, 'gnutimeout'));
|
||||||
|
fs.symlinkSync(execFileSync('which', ['git'], { encoding: 'utf8' }).trim(), path.join(bin, 'git'));
|
||||||
|
fs.symlinkSync(execFileSync('which', ['sort'], { encoding: 'utf8' }).trim(), path.join(bin, 'sort'));
|
||||||
|
fs.writeFileSync(path.join(bin, 'timeout'), '#!/bin/sh\nif [ "$1" = --version ]; then echo "timeout (uutils coreutils)"; exit 0; fi\nexit 98\n', { mode: 0o755 });
|
||||||
|
const args = [script, source, mirror, 'develop'];
|
||||||
|
const result = spawnSync('/bin/bash', args, options({ PATH: bin }));
|
||||||
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||||
|
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||||
|
fs.unlinkSync(path.join(bin, 'gnutimeout'));
|
||||||
|
const unsupported = spawnSync('/bin/bash', args, options({ PATH: bin }));
|
||||||
|
assert.equal(unsupported.status, 2, unsupported.stderr + unsupported.stdout);
|
||||||
|
assert.match(unsupported.stderr, /GNU timeout is required/);
|
||||||
|
});
|
||||||
|
|
||||||
test('mirror polling follows a source that advances while waiting', async (t) => {
|
test('mirror polling follows a source that advances while waiting', async (t) => {
|
||||||
const { source, mirror } = fixture(t);
|
const { source, mirror } = fixture(t);
|
||||||
git('-C', source, 'push', mirror, 'develop');
|
git('-C', source, 'push', mirror, 'develop');
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
serverRuntimeArgs,
|
||||||
|
} = require('../../cli/src/internal/maintenance/nodeRuntime.cjs');
|
||||||
|
|
||||||
|
test('server compatibility flags target ARMv6 Node 24 while retaining WebAssembly and the existing Node', () => {
|
||||||
|
for (const [arch, armVersion, version, enabled] of [
|
||||||
|
['arm', '6', '24.18.1', true],
|
||||||
|
['arm', 6, '24.18.1', true],
|
||||||
|
['arm', 6, '20.18.1', false],
|
||||||
|
['arm', 6, '22.20.0', false],
|
||||||
|
['arm', 7, '24.18.1', false],
|
||||||
|
['arm64', undefined, '24.18.1', false],
|
||||||
|
['x64', undefined, '24.18.1', false],
|
||||||
|
]) {
|
||||||
|
const flags = serverRuntimeArgs({
|
||||||
|
arch,
|
||||||
|
config: { variables: { arm_version: armVersion } },
|
||||||
|
versions: { node: version },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
flags,
|
||||||
|
enabled ? ['--no-opt', '--no-sparkplug', '--no-maglev'] : [],
|
||||||
|
);
|
||||||
|
assert.ok(!flags.includes('--jitless'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the normal PM2 application uses the same server arguments as prestart', () => {
|
||||||
|
const app = require('../../ecosystem.config.js').apps.find(
|
||||||
|
(x) => x.name === 'qinglong',
|
||||||
|
);
|
||||||
|
const flags = serverRuntimeArgs();
|
||||||
|
assert.deepEqual(app.node_args || [], flags);
|
||||||
|
if (!flags.length) assert.equal(app.node_args, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ARMv6 server flags retain real HTTP, fetch and undici parsing', () => {
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const flags = serverRuntimeArgs({
|
||||||
|
arch: 'arm',
|
||||||
|
config: { variables: { arm_version: 6 } },
|
||||||
|
versions: { node: '24.18.1' },
|
||||||
|
});
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
...flags,
|
||||||
|
'-e',
|
||||||
|
`
|
||||||
|
const http=require('http'),assert=require('assert/strict');
|
||||||
|
assert.equal(typeof WebAssembly,'object');
|
||||||
|
const server=http.createServer((req,res)=>res.end('parser-ok'));
|
||||||
|
server.listen(0,'127.0.0.1',async()=>{
|
||||||
|
try {
|
||||||
|
const url='http://127.0.0.1:'+server.address().port;
|
||||||
|
assert.equal(await (await fetch(url)).text(),'parser-ok');
|
||||||
|
assert.equal(await (await require('undici').request(url)).body.text(),'parser-ok');
|
||||||
|
} catch(e){console.error(e);process.exitCode=1;}
|
||||||
|
finally{server.closeAllConnections();server.close();}
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10000,
|
||||||
|
env: { ...process.env, NODE_OPTIONS: '' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.error?.message);
|
||||||
|
});
|
||||||
@@ -7,6 +7,67 @@ const tar = require('tar');
|
|||||||
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
const bundle = require('../../cli/src/internal/maintenance/platformBundle.cjs');
|
||||||
const artifacts = require('../../shell/upgrade.cjs');
|
const artifacts = require('../../shell/upgrade.cjs');
|
||||||
|
|
||||||
|
test('dependency probes allow slow ARMv6 startup while preserving cancellation and normal Node execution', async () => {
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const filename = require.resolve(
|
||||||
|
'../../cli/src/internal/maintenance/platformBundle.cjs',
|
||||||
|
);
|
||||||
|
const text = await fs.readFile(filename, 'utf8');
|
||||||
|
for (const [arch, armVersion, version, timeout] of [
|
||||||
|
['arm', 6, '20.18.1', 300000],
|
||||||
|
['arm', 6, '24.18.1', 300000],
|
||||||
|
['arm', 7, '24.18.1', 30000],
|
||||||
|
['x64', undefined, '24.18.1', 30000],
|
||||||
|
]) {
|
||||||
|
let launch;
|
||||||
|
const m = { exports: {} };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const localRequire = (name) => {
|
||||||
|
if (name === 'node:child_process')
|
||||||
|
return {
|
||||||
|
execFile(program, args, options, callback) {
|
||||||
|
launch = { program, args, options };
|
||||||
|
callback(null, '', '');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return require(require.resolve(name, {
|
||||||
|
paths: [path.dirname(filename)],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
vm.runInNewContext(
|
||||||
|
text,
|
||||||
|
{
|
||||||
|
module: m,
|
||||||
|
exports: m.exports,
|
||||||
|
require: localRequire,
|
||||||
|
process: {
|
||||||
|
arch,
|
||||||
|
config: { variables: { arm_version: armVersion } },
|
||||||
|
versions: { node: version },
|
||||||
|
execPath: process.execPath,
|
||||||
|
env: { NODE_OPTIONS: '--jitless', NODE_PATH: '/outside' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ filename },
|
||||||
|
);
|
||||||
|
await m.exports.smokeDependencies('/staged/payload', controller.signal);
|
||||||
|
assert.equal(launch.program, process.execPath);
|
||||||
|
assert.equal(launch.args.at(-2), '-e');
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from(launch.args.slice(0, -2)),
|
||||||
|
arch === 'arm' && armVersion === 6 && version.startsWith('24.')
|
||||||
|
? ['--no-opt', '--no-sparkplug', '--no-maglev']
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
assert.ok(!launch.args.includes('--jitless'));
|
||||||
|
assert.equal(launch.options.cwd, '/staged/payload');
|
||||||
|
assert.equal(launch.options.timeout, timeout);
|
||||||
|
assert.equal(launch.options.signal, controller.signal);
|
||||||
|
assert.equal(launch.options.env.NODE_OPTIONS, '');
|
||||||
|
assert.equal(launch.options.env.NODE_PATH, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const failure of ['prestart', 'repair'])
|
for (const failure of ['prestart', 'repair'])
|
||||||
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
|
test(`failed ${failure} preserves the installed dependencies and previous ready pointer`, async (t) => {
|
||||||
const f = await fixture(t);
|
const f = await fixture(t);
|
||||||
@@ -395,6 +456,7 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
|
|||||||
bundle.assertCompatible(manifest, target, '20.20.2');
|
bundle.assertCompatible(manifest, target, '20.20.2');
|
||||||
const glibcTarget = {
|
const glibcTarget = {
|
||||||
...target,
|
...target,
|
||||||
|
arch: 'armv7',
|
||||||
libc: 'glibc',
|
libc: 'glibc',
|
||||||
libcVersion: '2.31',
|
libcVersion: '2.31',
|
||||||
endianness: 'LE',
|
endianness: 'LE',
|
||||||
@@ -405,7 +467,7 @@ test('native target compatibility distinguishes ARMv6/ARMv7 and libc and retains
|
|||||||
target: { ...glibcTarget, libcMinimum: '2.31' },
|
target: { ...glibcTarget, libcMinimum: '2.31' },
|
||||||
};
|
};
|
||||||
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
|
bundle.assertCompatible(glibcManifest, glibcTarget, '20.18.1');
|
||||||
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv6-glibc');
|
assert.equal(bundle.targetKey(glibcTarget), 'linux-armv7-glibc');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
bundle.assertCompatible(glibcManifest, {
|
bundle.assertCompatible(glibcManifest, {
|
||||||
@@ -529,10 +591,10 @@ test('publishing requires every platform and validates all immutable archive has
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const index = releaseIndex(f.directory, sourceCommit, repository);
|
const index = releaseIndex(f.directory, sourceCommit, repository);
|
||||||
assert.equal(Object.keys(index.packages).length, 13);
|
assert.equal(Object.keys(index.packages).length, 12);
|
||||||
const armv6 = path.join(
|
const armv6 = path.join(
|
||||||
f.directory,
|
f.directory,
|
||||||
`qinglong-${sourceCommit}-linux-armv6-glibc.tar.gz`,
|
`qinglong-${sourceCommit}-linux-armv6-musl.tar.gz`,
|
||||||
);
|
);
|
||||||
const armv6Bytes = await fs.readFile(armv6);
|
const armv6Bytes = await fs.readFile(armv6);
|
||||||
await fs.rm(armv6);
|
await fs.rm(armv6);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const vm = require('node:vm');
|
const vm = require('node:vm');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const configRequire = require('node:module').createRequire(require('node:path').resolve('ecosystem.config.js'));
|
||||||
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
|
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
|
||||||
const appSource = fs.readFileSync('back/app.ts', 'utf8');
|
const appSource = fs.readFileSync('back/app.ts', 'utf8');
|
||||||
const ts = require('typescript');
|
const ts = require('typescript');
|
||||||
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
|
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
|
||||||
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
|
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env},require:configRequire});return module.exports.apps[0];}
|
||||||
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
||||||
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
|
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
|
||||||
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
|
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ function fixture(t) {
|
|||||||
const route = args[1];
|
const route = args[1];
|
||||||
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
if (route.endsWith('/git/ref/heads/master')) return state.head;
|
||||||
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
if (route.endsWith('/git/ref/heads/develop')) return state.developHead;
|
||||||
|
if (route.endsWith('/releases?per_page=100'))
|
||||||
|
return JSON.stringify([
|
||||||
|
Array.from(state.releases.keys(), (tag_name) => ({
|
||||||
|
tag_name,
|
||||||
|
prerelease: true,
|
||||||
|
draft: false,
|
||||||
|
...state.releaseMetadata?.[tag_name],
|
||||||
|
})),
|
||||||
|
]);
|
||||||
if (route.includes('/releases/tags/')) {
|
if (route.includes('/releases/tags/')) {
|
||||||
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
|
const tag = decodeURIComponent(route.split('/releases/tags/')[1]);
|
||||||
if (state.lookupFailure === tag)
|
if (state.lookupFailure === tag)
|
||||||
@@ -118,13 +127,23 @@ function fixture(t) {
|
|||||||
digest: `sha256:${sha256(bytes)}`,
|
digest: `sha256:${sha256(bytes)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (tag === 'upgrade-develop' && state.corruptChannel)
|
||||||
|
release.get('latest.json').digest = 'sha256:wrong';
|
||||||
if (files.length > 1) {
|
if (files.length > 1) {
|
||||||
if (state.corruptUploadedBatch)
|
if (state.corruptUploadedBatch)
|
||||||
release.get(path.basename(files[0])).digest = 'sha256:wrong';
|
release.get(path.basename(files[0])).digest = 'sha256:wrong';
|
||||||
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
|
if (state.changeHeadAfterUpload) state.head = 'b'.repeat(40);
|
||||||
|
if (state.changeDevelopHeadAfterUpload)
|
||||||
|
state.developHead = 'd'.repeat(40);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (args[0] === 'release' && args[1] === 'delete') {
|
||||||
|
if (state.failDelete) throw new Error('Delete unavailable');
|
||||||
|
assert.ok(state.releases.has(args[2]));
|
||||||
|
state.releases.delete(args[2]);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
if (args[0] === 'release' && args[1] === 'edit') return '';
|
if (args[0] === 'release' && args[1] === 'edit') return '';
|
||||||
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
|
throw new Error(`Unexpected gh call: ${JSON.stringify(args)}`);
|
||||||
};
|
};
|
||||||
@@ -143,10 +162,10 @@ test('publishes every platform and installer before advancing version and legacy
|
|||||||
assert.equal(index.version, '2.24.0');
|
assert.equal(index.version, '2.24.0');
|
||||||
assert.equal(index.buildId, '100-1');
|
assert.equal(index.buildId, '100-1');
|
||||||
assert.equal(index.sourceCommit, f.options.commit);
|
assert.equal(index.sourceCommit, f.options.commit);
|
||||||
assert.equal(Object.keys(index.packages).length, 13);
|
assert.equal(Object.keys(index.packages).length, 12);
|
||||||
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
assert.deepEqual(index, f.index('upgrade-v2.24.0'));
|
||||||
const tag = 'upgrade-build-v2.24.0-100-1';
|
const tag = 'upgrade-build-v2.24.0-100-1';
|
||||||
assert.equal(f.state.releases.get(tag).size, 28);
|
assert.equal(f.state.releases.get(tag).size, 26);
|
||||||
for (const entry of [...Object.values(index.packages), index.installer]) {
|
for (const entry of [...Object.values(index.packages), index.installer]) {
|
||||||
const url = new URL(entry.url),
|
const url = new URL(entry.url),
|
||||||
filename = path.basename(url.pathname);
|
filename = path.basename(url.pathname);
|
||||||
@@ -230,7 +249,7 @@ for (const fault of ['missing', 'corrupt', 'wrong-version'])
|
|||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
const archive = path.join(
|
const archive = path.join(
|
||||||
f.directory,
|
f.directory,
|
||||||
`qinglong-${f.options.commit}-linux-armv6-glibc.tar.gz`,
|
`qinglong-${f.options.commit}-linux-armv6-musl.tar.gz`,
|
||||||
);
|
);
|
||||||
if (fault === 'missing') fs.rmSync(archive);
|
if (fault === 'missing') fs.rmSync(archive);
|
||||||
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
|
else if (fault === 'corrupt') fs.appendFileSync(archive, 'corrupt');
|
||||||
@@ -288,7 +307,7 @@ test('an explicitly rerun earlier workflow may replace the same version and comm
|
|||||||
|
|
||||||
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
|
test('non-master branches, invalid build IDs and API failures cannot overwrite published versions', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
for (const branch of ['develop', 'v2.24.0', 'feature/test'])
|
for (const branch of ['debian-dev', 'v2.24.0', 'feature/test'])
|
||||||
assert.throws(() => f.publish({ branch }), /Only master/);
|
assert.throws(() => f.publish({ branch }), /Only master/);
|
||||||
for (const buildId of [undefined, '100', '100-1/other'])
|
for (const buildId of [undefined, '100', '100-1/other'])
|
||||||
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
|
assert.throws(() => f.publish({ buildId }), /Invalid bundle build ID/);
|
||||||
@@ -316,7 +335,7 @@ test('existing master clients can migrate from indexes without version/build met
|
|||||||
assert.equal(f.index().buildId, '100-2');
|
assert.equal(f.index().buildId, '100-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('explicit development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
test('automatic development publication shares musl/glibc packages without changing version or latest production assets', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
f.publish();
|
f.publish();
|
||||||
const production = f.index();
|
const production = f.index();
|
||||||
@@ -325,18 +344,17 @@ test('explicit development publication shares musl/glibc packages without changi
|
|||||||
f.publish({
|
f.publish({
|
||||||
branch: 'develop',
|
branch: 'develop',
|
||||||
commit: f.state.developHead,
|
commit: f.state.developHead,
|
||||||
allowDevelopment: true,
|
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
const development = f.index('upgrade-develop');
|
const development = f.index('upgrade-develop');
|
||||||
assert.equal(development.channel, 'develop');
|
assert.equal(development.channel, 'develop');
|
||||||
assert.equal(development.sourceCommit, f.state.developHead);
|
assert.equal(development.sourceCommit, f.state.developHead);
|
||||||
assert.equal(Object.keys(development.packages).length, 13);
|
assert.equal(Object.keys(development.packages).length, 12);
|
||||||
assert.ok(development.packages['linux-x64-musl']);
|
assert.ok(development.packages['linux-x64-musl']);
|
||||||
assert.ok(development.packages['linux-x64-glibc']);
|
assert.ok(development.packages['linux-x64-glibc']);
|
||||||
assert.ok(development.packages['linux-armv6-musl']);
|
assert.ok(development.packages['linux-armv6-musl']);
|
||||||
assert.ok(development.packages['linux-armv6-glibc']);
|
assert.equal(development.packages['linux-armv6-glibc'], undefined);
|
||||||
for (const entry of [
|
for (const entry of [
|
||||||
...Object.values(development.packages),
|
...Object.values(development.packages),
|
||||||
development.installer,
|
development.installer,
|
||||||
@@ -344,44 +362,30 @@ test('explicit development publication shares musl/glibc packages without changi
|
|||||||
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
assert.ok(entry.url.includes('/upgrade-build-develop-v2.24.0-100-1/'));
|
||||||
assert.deepEqual(f.index(), production);
|
assert.deepEqual(f.index(), production);
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||||
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 28);
|
assert.equal(f.state.releases.get('upgrade-build-v2.24.0-100-1').size, 26);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
f.state.releases.get('upgrade-build-develop-v2.24.0-100-1').size,
|
||||||
28,
|
26,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('development builds require explicit boolean opt-in and cannot publish obsolete development source', (t) => {
|
test('development builds cannot publish obsolete development source', (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
for (const allowDevelopment of [false, undefined, 'true'])
|
|
||||||
assert.throws(
|
|
||||||
() => f.publish({ branch: 'develop', allowDevelopment }),
|
|
||||||
/explicitly requested/,
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => f.publish({ branch: 'feature/test', allowDevelopment: true }),
|
|
||||||
/Only master/,
|
|
||||||
);
|
|
||||||
assert.equal(f.state.calls.length, 0);
|
|
||||||
f.write(f.state.developHead, 'development');
|
f.write(f.state.developHead, 'development');
|
||||||
const obsolete = f.state.developHead;
|
const obsolete = f.state.developHead;
|
||||||
f.state.developHead = 'd'.repeat(40);
|
f.state.developHead = 'd'.repeat(40);
|
||||||
assert.equal(
|
assert.equal(f.publish({ branch: 'develop', commit: obsolete }), false);
|
||||||
f.publish({ branch: 'develop', commit: obsolete, allowDevelopment: true }),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(f.state.releases.size, 0);
|
assert.equal(f.state.releases.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
||||||
test(`failed manual development publication preserves both channels (${fault})`, (t) => {
|
test(`failed automatic development publication preserves both channels (${fault})`, (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
f.publish();
|
f.publish();
|
||||||
const production = f.index();
|
const production = f.index();
|
||||||
const options = {
|
const options = {
|
||||||
branch: 'develop',
|
branch: 'develop',
|
||||||
commit: f.state.developHead,
|
commit: f.state.developHead,
|
||||||
allowDevelopment: true,
|
|
||||||
};
|
};
|
||||||
f.write(options.commit, 'first development');
|
f.write(options.commit, 'first development');
|
||||||
f.publish(options);
|
f.publish(options);
|
||||||
@@ -396,3 +400,98 @@ for (const fault of ['failBatchUpload', 'corruptUploadedBatch'])
|
|||||||
assert.deepEqual(f.index(), production);
|
assert.deepEqual(f.index(), production);
|
||||||
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const fault of [
|
||||||
|
null,
|
||||||
|
'failBatchUpload',
|
||||||
|
'corruptUploadedBatch',
|
||||||
|
'corruptChannel',
|
||||||
|
])
|
||||||
|
test(`development retention deletes old batches only after verified publication (${
|
||||||
|
fault || 'success'
|
||||||
|
})`, (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
f.publish();
|
||||||
|
const production = f.index();
|
||||||
|
const options = { branch: 'develop', commit: f.state.developHead };
|
||||||
|
f.write(options.commit, 'development');
|
||||||
|
f.publish(options);
|
||||||
|
const oldTag = 'upgrade-build-develop-v2.24.0-100-1';
|
||||||
|
// Never prune production, channel aliases, legacy/unclassified releases,
|
||||||
|
// drafts or a non-prerelease even when its name resembles a dev build.
|
||||||
|
const protectedTags = [
|
||||||
|
'upgrade-86c37f426a5ba9945d6af1910a7885a26f77e4bc',
|
||||||
|
'v2.24.0',
|
||||||
|
'upgrade-build-develop-v2.24.0-10-1',
|
||||||
|
'upgrade-build-develop-v2.24.0-11-1',
|
||||||
|
];
|
||||||
|
for (const tag of protectedTags) f.state.releases.set(tag, new Map());
|
||||||
|
f.state.releaseMetadata = {
|
||||||
|
[protectedTags[2]]: { draft: true },
|
||||||
|
[protectedTags[3]]: { prerelease: false },
|
||||||
|
};
|
||||||
|
f.write(options.commit, 'next development');
|
||||||
|
if (fault) f.state[fault] = true;
|
||||||
|
if (fault)
|
||||||
|
assert.throws(
|
||||||
|
() => f.publish({ ...options, buildId: '101-1' }),
|
||||||
|
/Upload interrupted|verification failed/,
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
assert.equal(f.publish({ ...options, buildId: '101-1' }), true);
|
||||||
|
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||||
|
const deletion = f.state.calls.findIndex(
|
||||||
|
(a) => a[0] === 'release' && a[1] === 'delete',
|
||||||
|
);
|
||||||
|
const aliasEdit = f.state.calls.findLastIndex(
|
||||||
|
(a) => a[0] === 'release' && a[1] === 'edit',
|
||||||
|
);
|
||||||
|
assert.ok(deletion > aliasEdit);
|
||||||
|
assert.ok(
|
||||||
|
f.state.calls
|
||||||
|
.slice(aliasEdit + 1, deletion)
|
||||||
|
.some(
|
||||||
|
(a) =>
|
||||||
|
a[0] === 'api' && a[1].includes('/releases/tags/upgrade-develop'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
||||||
|
}
|
||||||
|
assert.equal(f.state.releases.has(oldTag), Boolean(fault));
|
||||||
|
for (const tag of protectedTags) assert.ok(f.state.releases.has(tag));
|
||||||
|
assert.deepEqual(f.index(), production);
|
||||||
|
assert.deepEqual(f.index('upgrade-v2.24.0'), production);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed development cleanup is retryable without overwriting immutable assets', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const options = { branch: 'develop', commit: f.state.developHead };
|
||||||
|
f.write(options.commit, 'development');
|
||||||
|
f.publish(options);
|
||||||
|
f.state.failDelete = true;
|
||||||
|
assert.throws(
|
||||||
|
() => f.publish({ ...options, buildId: '101-1' }),
|
||||||
|
/Delete unavailable/,
|
||||||
|
);
|
||||||
|
assert.equal(f.index('upgrade-develop').buildId, '101-1');
|
||||||
|
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||||
|
f.state.failDelete = false;
|
||||||
|
f.publish({ ...options, buildId: '101-1' });
|
||||||
|
assert.ok(!f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||||
|
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-101-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newer development commit during upload preserves the published channel and its old package', (t) => {
|
||||||
|
const f = fixture(t);
|
||||||
|
const options = { branch: 'develop', commit: f.state.developHead };
|
||||||
|
f.write(options.commit, 'development');
|
||||||
|
f.publish(options);
|
||||||
|
const before = f.index('upgrade-develop');
|
||||||
|
f.state.changeDevelopHeadAfterUpload = true;
|
||||||
|
assert.equal(f.publish({ ...options, buildId: '101-1' }), false);
|
||||||
|
assert.deepEqual(f.index('upgrade-develop'), before);
|
||||||
|
assert.ok(f.state.releases.has('upgrade-build-develop-v2.24.0-100-1'));
|
||||||
|
assert.ok(
|
||||||
|
!f.state.calls.some((a) => a[0] === 'release' && a[1] === 'delete'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const path = require('node:path');
|
|||||||
const { spawnSync } = require('node:child_process');
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
const startup = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
||||||
const preparation = startup.slice(
|
const runtimeLoader = fs.readFileSync('shell/share.sh', 'utf8')
|
||||||
|
.match(/^load_node_runtime_args\(\) \{[\s\S]*?^\}/m)[0];
|
||||||
|
const preparation = `${runtimeLoader}\ndir_root="$QL_DIR"\n` + startup.slice(
|
||||||
startup.indexOf('\nfix_config\n'),
|
startup.indexOf('\nfix_config\n'),
|
||||||
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
|
startup.indexOf('log_with_style "INFO" "$(t \'🔄'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,61 @@ const os = require('node:os');
|
|||||||
const { execFileSync, spawnSync } = require('node:child_process');
|
const { execFileSync, spawnSync } = require('node:child_process');
|
||||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||||
|
|
||||||
|
test('both static publication steps supply a token as the HTTPS password and reject missing credentials', () => {
|
||||||
|
const workflow = require('js-yaml').load(
|
||||||
|
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
'copy to static repo',
|
||||||
|
'publish verified upgrade download sizes',
|
||||||
|
]) {
|
||||||
|
const step = workflow.jobs['build-static'].steps.find(
|
||||||
|
(step) => step.name === name,
|
||||||
|
);
|
||||||
|
assert.equal(step.env.STATIC_TOKEN, '${{ secrets.API_TOKEN }}');
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-auth-'));
|
||||||
|
try {
|
||||||
|
// Capture the real workflow command's argv, without contacting any remote.
|
||||||
|
for (const program of ['bash', 'node']) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmp, program),
|
||||||
|
'#!/bin/sh\nprintf "%s\\n" "$@" > "$QA_ARGS"\n',
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const argsFile = path.join(tmp, 'args');
|
||||||
|
const env = {
|
||||||
|
PATH: tmp,
|
||||||
|
QA_ARGS: argsFile,
|
||||||
|
STATIC_TOKEN: 'fixture-secret',
|
||||||
|
GITHUB_REPO: 'github.com/fixture/static',
|
||||||
|
GITHUB_BRANCH: 'develop',
|
||||||
|
GITHUB_REF_TYPE: 'branch',
|
||||||
|
GITHUB_SHA: 'a'.repeat(40),
|
||||||
|
};
|
||||||
|
const result = spawnSync('/bin/bash', ['-euc', step.run], {
|
||||||
|
env,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const remote = fs.readFileSync(argsFile, 'utf8').split('\n')[1];
|
||||||
|
const url = new URL(remote);
|
||||||
|
assert.equal(url.username, 'x-access-token');
|
||||||
|
assert.equal(url.password, env.STATIC_TOKEN);
|
||||||
|
fs.unlinkSync(argsFile);
|
||||||
|
assert.notEqual(
|
||||||
|
spawnSync('/bin/bash', ['-euc', step.run], {
|
||||||
|
env: { ...env, STATIC_TOKEN: '' },
|
||||||
|
}).status,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(argsFile), false);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => {
|
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-'));
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
|
||||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const yaml = require('js-yaml');
|
|||||||
const workflow = yaml.load(
|
const workflow = yaml.load(
|
||||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('shared server runtime changes trigger the image and upgrade verification workflow', () => {
|
||||||
|
const paths = workflow.on.push.paths;
|
||||||
|
assert.ok(
|
||||||
|
paths.indexOf('cli/src/internal/maintenance/nodeRuntime.cjs') >
|
||||||
|
paths.indexOf('!cli/**'),
|
||||||
|
);
|
||||||
|
});
|
||||||
// These conditions use GitHub's boolean operators and format(), which have the
|
// These conditions use GitHub's boolean operators and format(), which have the
|
||||||
// same semantics for the boolean/string contexts exercised here.
|
// same semantics for the boolean/string contexts exercised here.
|
||||||
const evaluate = (expression, github, inputs = {}) => {
|
const evaluate = (expression, github, inputs = {}) => {
|
||||||
@@ -25,25 +33,14 @@ const evaluate = (expression, github, inputs = {}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
test('both bundle jobs require master or an explicit development dispatch', () => {
|
test('both channels automatically build bundles, with static-only and Git-tag runs excluded', () => {
|
||||||
assert.equal(
|
|
||||||
workflow.on.workflow_dispatch.inputs.build_development_bundles.default,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const cases = [
|
const cases = [
|
||||||
['push', 'refs/heads/develop', {}, false],
|
['push', 'refs/heads/develop', {}, true],
|
||||||
['push', 'refs/heads/develop', { build_development_bundles: true }, false],
|
['workflow_dispatch', 'refs/heads/develop', {}, true],
|
||||||
['workflow_dispatch', 'refs/heads/develop', {}, false],
|
|
||||||
[
|
[
|
||||||
'workflow_dispatch',
|
'workflow_dispatch',
|
||||||
'refs/heads/develop',
|
'refs/heads/develop',
|
||||||
{ build_development_bundles: true },
|
{ static_sync_only: true },
|
||||||
true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'workflow_dispatch',
|
|
||||||
'refs/heads/develop',
|
|
||||||
{ build_development_bundles: true, static_sync_only: true },
|
|
||||||
false,
|
false,
|
||||||
],
|
],
|
||||||
['push', 'refs/heads/master', {}, true],
|
['push', 'refs/heads/master', {}, true],
|
||||||
@@ -55,12 +52,7 @@ test('both bundle jobs require master or an explicit development dispatch', () =
|
|||||||
false,
|
false,
|
||||||
],
|
],
|
||||||
['push', 'refs/tags/v2.24.0', {}, false],
|
['push', 'refs/tags/v2.24.0', {}, false],
|
||||||
[
|
['workflow_dispatch', 'refs/tags/v2.24.0', {}, false],
|
||||||
'workflow_dispatch',
|
|
||||||
'refs/tags/v2.24.0',
|
|
||||||
{ build_development_bundles: true },
|
|
||||||
false,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
|
for (const name of ['build-upgrade-bundles', 'publish-upgrade-bundles'])
|
||||||
for (const [event_name, ref, inputs, expected] of cases)
|
for (const [event_name, ref, inputs, expected] of cases)
|
||||||
@@ -84,26 +76,53 @@ test('both bundle jobs require master or an explicit development dispatch', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual development releases serialize separately while normal develop CI remains independent', () => {
|
test('development image and bundle publication serialize separately from production and static-only runs', () => {
|
||||||
const group = (event_name, ref, inputs = {}, run_id = '100') =>
|
const group = (event_name, ref, inputs = {}, run_id = '100') =>
|
||||||
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
|
evaluate(workflow.concurrency.group, { event_name, ref, run_id }, inputs);
|
||||||
const master = group('push', 'refs/heads/master');
|
const master = group('push', 'refs/heads/master');
|
||||||
const development = group('workflow_dispatch', 'refs/heads/develop', {
|
const development = group('push', 'refs/heads/develop');
|
||||||
build_development_bundles: true,
|
|
||||||
});
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
development,
|
development,
|
||||||
group(
|
group('workflow_dispatch', 'refs/heads/develop', {}, '101'),
|
||||||
'workflow_dispatch',
|
|
||||||
'refs/heads/develop',
|
|
||||||
{ build_development_bundles: true },
|
|
||||||
'101',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
|
assert.equal(master, group('push', 'refs/heads/master', {}, '101'));
|
||||||
assert.notEqual(master, development);
|
assert.notEqual(master, development);
|
||||||
const push = group('push', 'refs/heads/develop');
|
assert.notEqual(
|
||||||
assert.notEqual(push, development);
|
development,
|
||||||
assert.notEqual(push, group('push', 'refs/heads/develop', {}, '101'));
|
group('workflow_dispatch', 'refs/heads/develop', {
|
||||||
|
static_sync_only: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
group('push', 'refs/tags/v2.24.0'),
|
||||||
|
group('push', 'refs/tags/v2.24.0', {}, '101'),
|
||||||
|
);
|
||||||
assert.equal(workflow.concurrency['cancel-in-progress'], false);
|
assert.equal(workflow.concurrency['cancel-in-progress'], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bundle targets exactly match the source image platforms and publisher requirements', () => {
|
||||||
|
const targets =
|
||||||
|
workflow.jobs['build-upgrade-bundles'].strategy.matrix.include;
|
||||||
|
for (const [job, libc] of [
|
||||||
|
['build-alpine', 'musl'],
|
||||||
|
['build-debian', 'glibc'],
|
||||||
|
]) {
|
||||||
|
const platforms = workflow.jobs[job].steps
|
||||||
|
.find((step) => step.with?.platforms)
|
||||||
|
.with.platforms.split(',');
|
||||||
|
assert.deepEqual(
|
||||||
|
targets
|
||||||
|
.filter((t) => t.libc === libc)
|
||||||
|
.map((t) => t.platform)
|
||||||
|
.sort(),
|
||||||
|
platforms.sort(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { platforms } = require('../../scripts/publish-upgrade-bundles.cjs');
|
||||||
|
assert.deepEqual(
|
||||||
|
targets.map((t) => `linux-${t.name}`).sort(),
|
||||||
|
[...platforms].sort(),
|
||||||
|
);
|
||||||
|
assert.ok(platforms.includes('linux-armv6-musl'));
|
||||||
|
assert.ok(!platforms.includes('linux-armv6-glibc'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const ts = require('typescript');
|
||||||
|
const load = require('../helpers/load-security-module.cjs');
|
||||||
|
const { SchedulerReadiness } = load('back/shared/schedulerReadiness.ts');
|
||||||
|
const { outputText } = ts.transpileModule(
|
||||||
|
fs.readFileSync('back/bootstrap/upgradeProbe.ts', 'utf8'),
|
||||||
|
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run the real probe entry point, including its lifecycle and IPC reporting.
|
||||||
|
// Only external services and the clock are replaced; no test exports are needed.
|
||||||
|
async function runProbe({ readiness, arch = 'x64', armVersion, supervised = true }) {
|
||||||
|
let now = 0;
|
||||||
|
const events = [], waits = [], messages = [];
|
||||||
|
const build = { sourceCommit: 'a'.repeat(40) };
|
||||||
|
let finish;
|
||||||
|
const completed = new Promise((resolve) => { finish = resolve; });
|
||||||
|
const listener = { address: () => ({ port: 12345 }) };
|
||||||
|
const server = {
|
||||||
|
initialize: async () => { events.push('http'); return listener; },
|
||||||
|
shutdown: async () => { events.push('http-close'); },
|
||||||
|
};
|
||||||
|
const grpc = {
|
||||||
|
initialize: async () => { events.push('grpc'); return 12346; },
|
||||||
|
getServer: () => ({ forceShutdown: () => events.push('grpc-close') }),
|
||||||
|
};
|
||||||
|
class GrpcServerService {}
|
||||||
|
class HttpServerService {}
|
||||||
|
const modules = {
|
||||||
|
'reflect-metadata': {},
|
||||||
|
dns: { setDefaultResultOrder: () => {} },
|
||||||
|
'../config': {},
|
||||||
|
'../data': { sequelize: {
|
||||||
|
transaction: async (action) => action({}),
|
||||||
|
query: async () => [[{ value: 42 }]],
|
||||||
|
close: async () => { events.push('db-close'); },
|
||||||
|
} },
|
||||||
|
'../loaders/db': async () => { events.push('db'); },
|
||||||
|
typedi: { Container: { get: (service) => service === GrpcServerService ? grpc : server } },
|
||||||
|
'../services/grpc': { GrpcServerService },
|
||||||
|
'../services/http': { HttpServerService },
|
||||||
|
'../loaders/httpApplication': { createHttpApplication: async () => ({}) },
|
||||||
|
'../loaders/server': async () => {},
|
||||||
|
'../loaders/express': () => {},
|
||||||
|
'../loaders/app': {},
|
||||||
|
'../loaders/depInjector': () => {},
|
||||||
|
'../schedule/client': { readiness: {
|
||||||
|
configure: (restore) => readiness.configure(restore),
|
||||||
|
recover: async () => {
|
||||||
|
const ready = await readiness.recover();
|
||||||
|
if (ready) events.push('ready');
|
||||||
|
return ready;
|
||||||
|
},
|
||||||
|
} },
|
||||||
|
http: { get: (url, respond) => {
|
||||||
|
events.push('health');
|
||||||
|
const request = new EventEmitter();
|
||||||
|
request.setTimeout = () => {};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const response = new EventEmitter();
|
||||||
|
response.statusCode = 200;
|
||||||
|
respond(response);
|
||||||
|
response.emit('data', JSON.stringify({ code: 200, data: { status: 'ok', services: { grpc: true, http: true }, build } }));
|
||||||
|
response.emit('end');
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
vm.runInNewContext(outputText, {
|
||||||
|
exports: {},
|
||||||
|
require: (name) => {
|
||||||
|
assert.ok(Object.hasOwn(modules, name), `Unexpected probe dependency: ${name}`);
|
||||||
|
return modules[name];
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
env: { QL_UPGRADE_PROBE: supervised ? '1' : '0' }, arch,
|
||||||
|
config: { variables: { arm_version: armVersion } }, pid: 123,
|
||||||
|
once: () => {}, memoryUsage: () => ({}),
|
||||||
|
send: (message) => { messages.push(message); events.push('report'); },
|
||||||
|
exit: finish,
|
||||||
|
},
|
||||||
|
Date: { now: () => now },
|
||||||
|
setTimeout: (callback, ms) => {
|
||||||
|
assert.ok(ms > 0 && ms <= 250);
|
||||||
|
waits.push(ms);
|
||||||
|
now += ms;
|
||||||
|
queueMicrotask(callback);
|
||||||
|
},
|
||||||
|
console: { error: () => {} },
|
||||||
|
}, { filename: 'upgradeProbe.js' });
|
||||||
|
const exitCode = await completed;
|
||||||
|
return { exitCode, events, waits, messages, elapsed: now, build };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('upgrade probe survives cold scheduler recovery before checking HTTP and reporting readiness', async () => {
|
||||||
|
let attempts = 0, restored = false;
|
||||||
|
const readiness = new SchedulerReadiness(async () => {
|
||||||
|
if (++attempts < 3) throw new Error('Failed to connect before the deadline');
|
||||||
|
});
|
||||||
|
// The probe must configure and finish restoration before reporting readiness.
|
||||||
|
const configure = readiness.configure.bind(readiness);
|
||||||
|
readiness.configure = (restore) => configure(async () => { await restore(); restored = true; });
|
||||||
|
const result = await runProbe({ readiness });
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.equal(restored, true);
|
||||||
|
assert.equal(await readiness.check(), true);
|
||||||
|
assert.deepEqual(result.waits, [250, 250]);
|
||||||
|
assert.deepEqual(result.events, ['db', 'grpc', 'ready', 'http', 'health', 'report', 'http-close', 'grpc-close', 'db-close']);
|
||||||
|
assert.equal(result.messages.length, 1);
|
||||||
|
assert.equal(result.messages[0].ok, true);
|
||||||
|
assert.equal(result.messages[0].build.sourceCommit, result.build.sourceCommit);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [arch, armVersion, timeout] of [['x64', undefined, 10000], ['arm', 7, 10000], ['arm', '6', 60000]]) {
|
||||||
|
test(`unavailable ${arch}/v${armVersion} scheduler fails at its startup budget and cleans up`, async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const result = await runProbe({ arch, armVersion, readiness: {
|
||||||
|
configure: () => {}, recover: async () => { attempts++; return false; },
|
||||||
|
} });
|
||||||
|
assert.equal(result.exitCode, 1);
|
||||||
|
assert.equal(result.elapsed, timeout);
|
||||||
|
assert.equal(attempts, timeout / 250);
|
||||||
|
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
||||||
|
assert.equal(result.messages.length, 1);
|
||||||
|
assert.equal(result.messages[0].ok, false);
|
||||||
|
assert.match(result.messages[0].message, /Upgrade scheduler readiness failed/);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(attempts, timeout / 250, 'no retries after failure');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unexpected scheduler recovery errors fail prestart without retries or a success report', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const result = await runProbe({ readiness: {
|
||||||
|
configure: () => {}, recover: async () => { attempts++; throw new Error('broken scheduler configuration'); },
|
||||||
|
} });
|
||||||
|
assert.equal(attempts, 1);
|
||||||
|
assert.equal(result.exitCode, 1);
|
||||||
|
assert.deepEqual(result.waits, []);
|
||||||
|
assert.deepEqual(result.events, ['db', 'grpc', 'http-close', 'grpc-close', 'db-close', 'report']);
|
||||||
|
assert.equal(result.messages.length, 1);
|
||||||
|
assert.equal(result.messages[0].ok, false);
|
||||||
|
assert.match(result.messages[0].message, /broken scheduler configuration/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already ready scheduler reaches health without waiting', async () => {
|
||||||
|
const result = await runProbe({ readiness: { configure: () => {}, recover: async () => true } });
|
||||||
|
assert.equal(result.exitCode, 0);
|
||||||
|
assert.deepEqual(result.waits, []);
|
||||||
|
assert.equal(result.messages[0].ok, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the upgrade probe still requires its isolated supervisor', async () => {
|
||||||
|
const result = await runProbe({ supervised: false });
|
||||||
|
assert.equal(result.exitCode, 1);
|
||||||
|
assert.deepEqual(result.events, ['report']);
|
||||||
|
assert.match(result.messages[0].message, /requires its isolated supervisor/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user