mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-02 06:58:52 +08:00
fix: ship verified npm panel packages and test production migrations
This commit is contained in:
@@ -94,6 +94,9 @@ jobs:
|
|||||||
pnpm build:back
|
pnpm build:back
|
||||||
pnpm build:info
|
pnpm build:info
|
||||||
|
|
||||||
|
- name: Pack the npm panel archive from the verified build
|
||||||
|
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: qinglong-static-${{ github.sha }}
|
name: qinglong-static-${{ github.sha }}
|
||||||
@@ -101,6 +104,13 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: copy to static repo
|
- name: copy to static repo
|
||||||
env:
|
env:
|
||||||
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static
|
||||||
@@ -538,30 +548,78 @@ jobs:
|
|||||||
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
cache-from: type=registry,ref=whyour/qinglong:cache-debian-python3.10
|
||||||
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
cache-to: type=registry,ref=whyour/qinglong:cache-debian-python3.10,mode=max
|
||||||
|
|
||||||
|
# One unmodified amd64 package for migration acceptance only. This does not
|
||||||
|
# publish bundles or advance development/formal channel indexes.
|
||||||
|
build-npm-migration-bundle:
|
||||||
|
needs: build-debian
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
- name: Export the official platform package from this run's image digest
|
||||||
|
env:
|
||||||
|
IMAGE_DIGEST: ${{ needs.build-debian.outputs.image-digest }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||||
|
image="ghcr.io/${GITHUB_REPOSITORY}@${IMAGE_DIGEST}"
|
||||||
|
docker buildx build --platform linux/amd64 --build-arg "BASE_IMAGE=$image" --file docker/Dockerfile.upgrade-bundle --output "type=local,dest=$RUNNER_TEMP/npm-migration-bundle" .
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: npm-migration-bundle-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-migration-bundle/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
verify-npm-package:
|
||||||
|
needs: [build-static, build-npm-migration-bundle]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node: ['20.18.1', '24']
|
||||||
|
container: node:${{ matrix.node }}-bookworm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel/
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: npm-migration-bundle-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-migration-bundle/
|
||||||
|
- name: Install isolated service test tools
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends python3 openssl curl jq nginx procps
|
||||||
|
# The published legacy launcher resets PATH to standard directories.
|
||||||
|
# /usr/local is confined to this disposable job container; PM2_HOME
|
||||||
|
# remains private to each verification run.
|
||||||
|
npm install --global --prefix /usr/local --no-audit --no-fund pm2@6.0.14
|
||||||
|
- name: Install and upgrade the exact npm archive
|
||||||
|
env:
|
||||||
|
QL_PANEL_PACKAGE_TEST_ISOLATED: '1'
|
||||||
|
QL_PANEL_TEST_PM2: /usr/local/bin/pm2
|
||||||
|
QL_PANEL_TEST_BUNDLE_DIR: ${{ runner.temp }}/npm-migration-bundle
|
||||||
|
run: node scripts/verify-panel-package.cjs verify "$RUNNER_TEMP/npm-panel" "$RUNNER_TEMP/npm-panel-results"
|
||||||
|
- uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-verification-${{ matrix.node }}-${{ github.sha }}
|
||||||
|
path: ${{ runner.temp }}/npm-panel-results/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
if: ${{ github.repository == 'whyour/qinglong' && github.ref == 'refs/heads/master' }}
|
||||||
needs: [build-alpine, build-debian, build-alpine310, build-debian310]
|
needs: [build-alpine, build-debian, build-alpine310, build-debian310, verify-npm-package]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: "8.3.1"
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: build front and back
|
|
||||||
run: |
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
pnpm build:front
|
|
||||||
pnpm build:back
|
|
||||||
|
|
||||||
- name: Set up Node for trusted publishing
|
- name: Set up Node for trusted publishing
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@@ -569,17 +627,30 @@ jobs:
|
|||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: qinglong-npm-${{ github.sha }}
|
||||||
|
path: npm-panel/
|
||||||
|
|
||||||
- name: Publish npm package with OIDC
|
- name: Publish npm package with OIDC
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
name=$(node -p 'require("./package.json").name')
|
shopt -s nullglob
|
||||||
version=$(node -p 'require("./package.json").version')
|
archives=(npm-panel/*.tgz)
|
||||||
|
if [ "${#archives[@]}" -ne 1 ]; then
|
||||||
|
echo '::error::Expected exactly one verified panel npm archive.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
archive="${archives[0]}"
|
||||||
|
metadata=$(tar -xOf "$archive" package/package.json)
|
||||||
|
name=$(jq -er '.name | select(. == "@whyour/qinglong")' <<< "$metadata")
|
||||||
|
version=$(jq -er '.version | select(type == "string")' <<< "$metadata")
|
||||||
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
if npm view "$name@$version" version --json --registry=https://registry.npmjs.org > "$RUNNER_TEMP/npm-version.json" 2> "$RUNNER_TEMP/npm-version-error.log"; then
|
||||||
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
jq -e --arg version "$version" '. == $version' "$RUNNER_TEMP/npm-version.json" > /dev/null
|
||||||
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
echo "::notice::$name@$version is already published; skipping npm publication for this image rebuild."
|
||||||
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
elif jq -e '.error.code == "E404"' "$RUNNER_TEMP/npm-version.json" > /dev/null; then
|
||||||
npm publish --access public --registry=https://registry.npmjs.org
|
npm publish "$archive" --ignore-scripts --access public --registry=https://registry.npmjs.org
|
||||||
else
|
else
|
||||||
echo '::error::Could not check the published npm version; refusing to publish.'
|
echo '::error::Could not check the published npm version; refusing to publish.'
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ __pycache__
|
|||||||
/shell/preload/*-notify.json
|
/shell/preload/*-notify.json
|
||||||
/shell/preload/__ql_notify__.*
|
/shell/preload/__ql_notify__.*
|
||||||
/shell/preload/lang_env.sh
|
/shell/preload/lang_env.sh
|
||||||
|
/shell/upgrade-lock.yaml
|
||||||
|
|
||||||
.deepseek/
|
.deepseek/
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+11
-5
@@ -4,19 +4,25 @@
|
|||||||
/.history/
|
/.history/
|
||||||
/back/**/*.ts
|
/back/**/*.ts
|
||||||
/back/**/*.json
|
/back/**/*.json
|
||||||
/cli/
|
|
||||||
/data/
|
/data/
|
||||||
/src/
|
/src/
|
||||||
/static/**/*.js.map
|
|
||||||
/static/**/*.gz
|
|
||||||
/.editorconfig
|
/.editorconfig
|
||||||
/.gitignore
|
/.gitignore
|
||||||
/.prettierignore
|
/.prettierignore
|
||||||
/.prettierrc
|
/.prettierrc
|
||||||
/.umirc.ts
|
/.umirc.ts
|
||||||
/nodemon.json
|
/nodemon.json
|
||||||
/pnpm-lock.yaml
|
|
||||||
/tsconfig.back.json
|
/tsconfig.back.json
|
||||||
/tsconfig.json
|
/tsconfig.json
|
||||||
/typings.d.ts
|
/typings.d.ts
|
||||||
/.env
|
/.env
|
||||||
|
/sample/tool.ts
|
||||||
|
/shell/pub.sh
|
||||||
|
/shell/preload/env.*
|
||||||
|
/shell/preload/notify.*
|
||||||
|
/shell/preload/__ql_notify__.*
|
||||||
|
/shell/preload/lang_env.sh
|
||||||
|
/shell/preload/*-notify.json
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/.DS_Store
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ QL_DIR=/opt/qinglong QL_DATA_DIR=/opt/qinglong/data bash /opt/qinglong/shell/sta
|
|||||||
|
|
||||||
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 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。
|
||||||
|
|
||||||
|
## npm 面板包
|
||||||
|
|
||||||
|
`@whyour/qinglong` 仍通过 npm 安装生产依赖;它包含运行脚本、gRPC proto、升级引擎和完整前后端构建,不包含用户数据、Node、开发 CLI 或生产 `node_modules`。`@whyour/qinglong-cli` 是单独的远程管理包。
|
||||||
|
|
||||||
|
npm 会排除根目录的 `pnpm-lock.yaml`。主包的原生 `npm pack/publish` 在 `prepack` 中核对当前 Git 提交、干净工作区、锁文件及完整构建文件的哈希,通过后才将锁文件复制为 `shell/upgrade-lock.yaml`,安装时由仅使用 Node 内置模块的 `postinstall` 校验 SHA256 后恢复标准路径;源代码安装和 Docker 的最小依赖构建阶段继续使用原有锁文件。归档使用精确运行文件白名单,避免打入本地生成的预加载配置、缓存、测试和验收输出。静态构建中的 source map 与 gzip 文件也必须完整保留,否则清单复验会失败。
|
||||||
|
|
||||||
|
干净源码完成 `pnpm build:front`、`pnpm build:back`、`pnpm build:info` 后,运行 `node scripts/verify-panel-package.cjs pack /tmp/qinglong-npm` 生成并检查真实 npm 归档。CI 在一次性 Linux 容器中,以 Node 20.18.1 和 Node 24 安装同一个归档,使用真实生产依赖和已安装的 `qinglong`、`ql` 入口,验证启动、损坏下载保留旧服务、在线准备与重启、离线准备与 Bash 重启及账号、配置、环境变量、任务和脚本保留。测试还安装完整性摘要固定的已发布 `@whyour/qinglong@2.23.0`,在旧服务中建立真实数据,再用新 `.tgz` 执行 npm 覆盖安装,验证账号、配置、环境变量、任务和脚本保留。之后升级到同次 Debian 镜像不可变 digest 通过正式 `Dockerfile.upgrade-bundle` 导出的原始平台包,确认运行目录变为 pnpm 布局。该单平台验收包仅上传短期 CI artifact,不发布 Release,也不推进开发或正式更新索引。
|
||||||
|
|
||||||
|
服务集成测试只能在专用容器中运行:设置 `QL_PANEL_PACKAGE_TEST_ISOLATED=1`、指向隔离 PM2 的 `QL_PANEL_TEST_PM2` 和指向正式构建脚本输出目录的 `QL_PANEL_TEST_BUNDLE_DIR`,然后执行 `node scripts/verify-panel-package.cjs verify /tmp/qinglong-npm /tmp/qinglong-npm-results`。容器需已有 Bash、curl、OpenSSL、Python、jq、nginx 和 procps。旧正式包会重置 PATH,因此验收容器将 PM2 6.0.14 安装到 `/usr/local/bin/pm2`,并以此设置 `QL_PANEL_TEST_PM2`;隔离通过专属容器和独立 `PM2_HOME` 保证。测试创建独立的 npm prefix、HOME、PM2_HOME 和数据目录,结束后清理。
|
||||||
|
|
||||||
|
通过 npm 覆盖升级前,应先停止服务,备份安装目录中的自定义 `.env`,执行新包安装,再恢复 `.env` 并启动服务;持久数据必须放在包目录以外的 `QL_DATA_DIR`。npm 会替换整个包目录,不负责保留包内用户文件。验收按这一流程备份和恢复自定义 `.env`,并直接校验外置用户数据。在线和离线平台包升级仍由事务保留现有 `.env`。
|
||||||
|
|
||||||
|
master 发布任务等待上述测试通过,直接发布已验证的 `.tgz`,不重新构建或打包。develop 只生成测试归档与验收记录,不发布 npm。
|
||||||
|
|
||||||
## 平台兼容
|
## 平台兼容
|
||||||
|
|
||||||
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
- 最低 Node 为 20.18.1,Node-API 至少 6;安装器只检查,不替换 Node。
|
||||||
|
|||||||
+42
-1
@@ -12,6 +12,45 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/whyour/qinglong/issues"
|
"url": "https://github.com/whyour/qinglong/issues"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".env.example",
|
||||||
|
"back/protos/api.proto",
|
||||||
|
"cli/src/internal/maintenance/*.cjs",
|
||||||
|
"ecosystem.config.js",
|
||||||
|
"README*.md",
|
||||||
|
"sample/*.sample.*",
|
||||||
|
"sample/notify.js",
|
||||||
|
"sample/notify.py",
|
||||||
|
"sample/ql_sample.js",
|
||||||
|
"sample/ql_sample.py",
|
||||||
|
"shell/*.cjs",
|
||||||
|
"shell/api.sh",
|
||||||
|
"shell/bot.sh",
|
||||||
|
"shell/check.sh",
|
||||||
|
"shell/env.sh",
|
||||||
|
"shell/lang/*.sh",
|
||||||
|
"shell/node_path_cache.sh",
|
||||||
|
"shell/otask.sh",
|
||||||
|
"shell/preload/client.js",
|
||||||
|
"shell/preload/client.py",
|
||||||
|
"shell/preload/esm-loader.mjs",
|
||||||
|
"shell/preload/notificationAudit.js",
|
||||||
|
"shell/preload/sitecustomize.js",
|
||||||
|
"shell/preload/sitecustomize.py",
|
||||||
|
"shell/rmlog.sh",
|
||||||
|
"shell/share.sh",
|
||||||
|
"shell/start-simplify.sh",
|
||||||
|
"shell/start.sh",
|
||||||
|
"shell/task.sh",
|
||||||
|
"shell/task-timeout.sh",
|
||||||
|
"shell/update.sh",
|
||||||
|
"shell/upgrade-lock.yaml",
|
||||||
|
"static/",
|
||||||
|
"version.yaml"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:cli": "node cli/scripts/build.cjs",
|
"build:cli": "node cli/scripts/build.cjs",
|
||||||
"check:cli": "npm --prefix cli run check",
|
"check:cli": "npm --prefix cli run check",
|
||||||
@@ -28,7 +67,9 @@
|
|||||||
"panel": "npm run build:back && node static/build/app.js",
|
"panel": "npm run build:back && node static/build/app.js",
|
||||||
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
|
"gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
|
||||||
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
|
"prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
|
||||||
"postinstall": "max setup 2>/dev/null || true",
|
"prepack": "node scripts/prepare-npm-package.cjs",
|
||||||
|
"postpack": "node scripts/prepare-npm-package.cjs --clean",
|
||||||
|
"postinstall": "node -e \"const fs = require('fs'); if (fs.existsSync('shell/install-package.cjs')) require('./shell/install-package.cjs').restorePackageLock(); else fs.accessSync('pnpm-lock.yaml')\" && (max setup 2>/dev/null || true)",
|
||||||
"start:simple": "bash shell/start-simplify.sh"
|
"start:simple": "bash shell/start-simplify.sh"
|
||||||
},
|
},
|
||||||
"gitHooks": {
|
"gitHooks": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// npm-packlist always excludes the root pnpm lockfile, even with a files entry.
|
||||||
|
// Carry a checked copy under a runtime filename and restore it after installation.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
const root = path.resolve(__dirname, '..');
|
||||||
|
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
||||||
|
if (process.argv.includes('--clean')) {
|
||||||
|
fs.rmSync(copy, { force: true });
|
||||||
|
} else {
|
||||||
|
if (execFileSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' }).trim())
|
||||||
|
throw new Error('Pack only from a clean source checkout');
|
||||||
|
// The native npm pack/publish hooks must enforce the same checks as CI:
|
||||||
|
// current commit, clean build, lockfile and every output file's checksum.
|
||||||
|
// Capture stdout so npm pack --json remains machine-readable.
|
||||||
|
execFileSync(process.execPath, [path.join(root, 'docker/verify-build.cjs')], { cwd: root });
|
||||||
|
const bytes = fs.readFileSync(path.join(root, 'pnpm-lock.yaml'));
|
||||||
|
fs.rmSync(copy, { force: true });
|
||||||
|
fs.writeFileSync(copy, bytes, { flag: 'wx', mode: 0o644 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
// Run service tests only inside a disposable Linux container or CI job.
|
||||||
|
// Unlike source-tree tests, these load the published entrypoints and dependencies.
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const sync = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createHash, randomUUID } = require('node:crypto');
|
||||||
|
const { createRequire } = require('node:module');
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
const { promisify } = require('node:util');
|
||||||
|
const { setTimeout: delay } = require('node:timers/promises');
|
||||||
|
const execute = promisify(execFile);
|
||||||
|
const repository = path.resolve(__dirname, '..');
|
||||||
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
const read = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
|
||||||
|
const run = (program, args, options = {}) => execute(program, args, {
|
||||||
|
encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 120000,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const requiredFiles = [
|
||||||
|
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
||||||
|
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
||||||
|
'shell/upgrade-lock.yaml', 'shell/start.sh', 'shell/start-simplify.sh',
|
||||||
|
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
||||||
|
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
||||||
|
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||||
|
'sample/config.sample.sh', 'sample/auth.sample.json',
|
||||||
|
'static/build-info.json', 'static/build/app.js', 'static/build/token.js',
|
||||||
|
'static/build/bootstrap/upgradeProbe.js', 'static/dist/index.html',
|
||||||
|
...['upgradeArtifacts', 'platformBundle', 'upgradePrestart', 'panelUpgrade']
|
||||||
|
.map((name) => `cli/src/internal/maintenance/${name}.cjs`),
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkPackedFiles(files) {
|
||||||
|
const names = new Set(files.map((file) => file.path));
|
||||||
|
for (const name of requiredFiles) assert.ok(names.has(name), `Missing npm runtime: ${name}`);
|
||||||
|
for (const name of names) {
|
||||||
|
assert.ok(/^(?:package\.json$|LICENSE$|README[^/]*\.md$|\.env\.example$|ecosystem\.config\.js$|version\.yaml$|back\/protos\/api\.proto$|cli\/(?:README[^/]*\.md$|src\/internal\/maintenance\/[^/]+\.cjs$)|sample\/|shell\/|static\/)/.test(name), `Unexpected npm file: ${name}`);
|
||||||
|
assert.ok(!/^shell\/(?:pub\.sh$|preload\/(?:env\.|notify\.|__ql_notify__\.|lang_env\.sh|.*-notify\.json))/.test(name), `Generated or release-only runtime file: ${name}`);
|
||||||
|
assert.ok(!/(?:^|\/)__pycache__\/|\.pyc$|(?:^|\/)\.DS_Store$/.test(name), name);
|
||||||
|
assert.notEqual(name, 'sample/tool.ts');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function packPanel(output) {
|
||||||
|
const git = (...args) => run('git', args, { cwd: repository });
|
||||||
|
assert.equal((await git('status', '--porcelain', '--untracked-files=all')).stdout.trim(), '', 'Pack only from a clean source checkout');
|
||||||
|
await run(process.execPath, [path.join(repository, 'docker/verify-build.cjs')], { cwd: repository });
|
||||||
|
await fs.mkdir(output, { recursive: true });
|
||||||
|
const cache = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-pack-cache-'));
|
||||||
|
try {
|
||||||
|
const result = await run('npm', ['pack', '--json', '--cache', cache, '--pack-destination', output], { cwd: repository });
|
||||||
|
const [manifest] = JSON.parse(result.stdout);
|
||||||
|
checkPackedFiles(manifest.files);
|
||||||
|
assert.equal(manifest.name, '@whyour/qinglong');
|
||||||
|
const build = await read(path.join(repository, 'static/build-info.json'));
|
||||||
|
const names = new Set(manifest.files.map((file) => file.path));
|
||||||
|
for (const name of Object.keys(build.files)) assert.ok(names.has(`static/${name}`), `npm filtered a verified build artifact: ${name}`);
|
||||||
|
const receipt = {
|
||||||
|
filename: manifest.filename, sha256: hash(await fs.readFile(path.join(output, manifest.filename))),
|
||||||
|
sourceCommit: build.sourceCommit, size: manifest.size, fileCount: manifest.files.length,
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(output, 'package-receipt.json'), JSON.stringify(receipt, null, 2));
|
||||||
|
console.log(JSON.stringify(receipt));
|
||||||
|
return receipt;
|
||||||
|
} finally {
|
||||||
|
await fs.rm(cache, { recursive: true, force: true });
|
||||||
|
// Also recover an interrupted npm pack whose postpack hook did not execute.
|
||||||
|
await fs.rm(path.join(repository, 'shell/upgrade-lock.yaml'), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unusedPort() {
|
||||||
|
const server = net.createServer();
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the unmodified output of Dockerfile.upgrade-bundle / buildBundle.
|
||||||
|
async function platformFixture(root, directory, expectedCommit) {
|
||||||
|
assert.ok(directory, 'Set QL_PANEL_TEST_BUNDLE_DIR to the official platform bundle output');
|
||||||
|
const names = (await fs.readdir(directory)).filter((name) => name.endsWith('.tar.gz'));
|
||||||
|
assert.equal(names.length, 1, 'Expected one official platform package');
|
||||||
|
const archive = path.resolve(directory, names[0]);
|
||||||
|
const record = await read(archive + '.json');
|
||||||
|
const bytes = await fs.readFile(archive);
|
||||||
|
assert.equal(record.format, 2);
|
||||||
|
assert.equal(record.sourceCommit, expectedCommit);
|
||||||
|
assert.equal(record.filename, names[0]);
|
||||||
|
assert.equal(record.sha256, hash(bytes));
|
||||||
|
assert.equal(record.size, bytes.length);
|
||||||
|
assert.match(record.baseImage, /^ghcr\.io\/[^@]+@sha256:[a-f0-9]{64}$/);
|
||||||
|
const local = createRequire(path.join(root, 'package.json'));
|
||||||
|
const bundle = local('./cli/src/internal/maintenance/platformBundle.cjs');
|
||||||
|
assert.equal(record.target, bundle.targetKey(await bundle.runtimeTarget()));
|
||||||
|
return { ...record, archive };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function legacyArchive(temp, env) {
|
||||||
|
const directory = path.join(temp, 'legacy');
|
||||||
|
await fs.mkdir(directory);
|
||||||
|
const result = await run('npm', ['pack', '@whyour/qinglong@2.23.0', '--ignore-scripts', '--json', '--registry=https://registry.npmjs.org', '--pack-destination', directory, '--cache', path.join(temp, 'cache')], { cwd: temp, env, timeout: 300000 });
|
||||||
|
const [record] = JSON.parse(result.stdout);
|
||||||
|
assert.equal(record.name, '@whyour/qinglong');
|
||||||
|
assert.equal(record.version, '2.23.0');
|
||||||
|
assert.equal(record.filename, 'whyour-qinglong-2.23.0.tgz');
|
||||||
|
const archive = path.join(directory, record.filename);
|
||||||
|
const integrity = 'sha512-' + createHash('sha512').update(await fs.readFile(archive)).digest('base64');
|
||||||
|
assert.equal(integrity, 'sha512-x6B9s1/SRKkHe7jRQtb3LzMluJ3O3PAAssrr/5jaFMMl7DZvABSEiqkkZMu0r1BNtiCF72uj8OqXN9pCnF2WVw==', 'Published legacy package integrity changed');
|
||||||
|
return { archive, integrity, version: record.version };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPanel(input, output) {
|
||||||
|
assert.equal(process.platform, 'linux', 'Service tests require Linux');
|
||||||
|
assert.equal(process.env.QL_PANEL_PACKAGE_TEST_ISOLATED, '1', 'Run only inside a disposable Linux container: QL_PANEL_PACKAGE_TEST_ISOLATED=1');
|
||||||
|
const stat = await fs.stat(input);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const names = (await fs.readdir(input)).filter((name) => name.endsWith('.tgz'));
|
||||||
|
assert.equal(names.length, 1, 'Expected one exact npm archive');
|
||||||
|
input = path.join(input, names[0]);
|
||||||
|
}
|
||||||
|
const expected = await read(path.join(path.dirname(input), 'package-receipt.json'));
|
||||||
|
assert.equal(path.basename(input), expected.filename);
|
||||||
|
assert.equal(hash(await fs.readFile(input)), expected.sha256, 'npm archive differs from its build receipt');
|
||||||
|
assert.equal((await fs.stat(input)).size, expected.size);
|
||||||
|
assert.match(expected.sourceCommit, /^[a-f0-9]{40}$/);
|
||||||
|
if (process.env.GITHUB_SHA) assert.equal(expected.sourceCommit, process.env.GITHUB_SHA);
|
||||||
|
const nodeSha = hash(await fs.readFile(process.execPath));
|
||||||
|
const pm2 = process.env.QL_PANEL_TEST_PM2;
|
||||||
|
assert.ok(pm2 && path.isAbsolute(pm2), 'Set QL_PANEL_TEST_PM2 to an isolated PM2 executable');
|
||||||
|
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-install-')));
|
||||||
|
const home = path.join(temp, 'home'), prefix = path.join(temp, 'prefix'), data = path.join(temp, 'data');
|
||||||
|
await fs.mkdir(home);
|
||||||
|
await fs.mkdir(data);
|
||||||
|
const port = await unusedPort();
|
||||||
|
let grpcPort = await unusedPort();
|
||||||
|
while (grpcPort === port) grpcPort = await unusedPort();
|
||||||
|
const env = { ...process.env, HOME: home, PM2_HOME: path.join(temp, '.pm2'),
|
||||||
|
QL_DATA_DIR: data, QL_BRANCH: 'develop', NODE_ENV: 'production', QL_CONTAINER: 'false',
|
||||||
|
BACK_PORT: String(port), GRPC_PORT: String(grpcPort), QlPort: String(port), QlGrpcPort: String(grpcPort),
|
||||||
|
BIND_HOST: '127.0.0.1', BIND_HOST_GRPC: '127.0.0.1',
|
||||||
|
NODE_PATH: '', NODE_OPTIONS: '',
|
||||||
|
PATH: [path.join(prefix, 'bin'), path.dirname(pm2), process.env.PATH].join(path.delimiter),
|
||||||
|
};
|
||||||
|
for (const key of ['QL_CLI_ROOT', 'QL_DIR', 'QL_UPGRADE_FILE', 'QL_UPGRADE_REPAIR_DEPS', 'QL_UPGRADE_LAUNCHER_PID', 'QL_UPGRADE_INDEX_URL']) delete env[key];
|
||||||
|
let root, fixtureServer, token;
|
||||||
|
const api = async (route, method = 'GET', body, anonymous = false) => {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/${route}`, {
|
||||||
|
method, headers: { 'Content-Type': 'application/json', ...(!anonymous && token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
assert.equal(result.code, 200, `${method} ${route}: ${result.message}`);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
const healthy = async () => {
|
||||||
|
const deadline = Date.now() + 120000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try { const value = await api('health', 'GET', undefined, true); if (value.status === 'ok') return value; } catch {}
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
throw new Error('Installed npm backend did not become healthy');
|
||||||
|
};
|
||||||
|
const job = async (id, status) => {
|
||||||
|
const deadline = Date.now() + 240000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const value = await api('system/update-status');
|
||||||
|
assert.equal(value.id, id);
|
||||||
|
if (value.status === status) return value;
|
||||||
|
if (value.status === 'failed' && status !== 'failed') throw new Error(value.error);
|
||||||
|
} catch (error) {
|
||||||
|
if (!/fetch failed|ECONNRESET|ECONNREFUSED|aborted|timeout/i.test(error.message)) throw error;
|
||||||
|
}
|
||||||
|
await delay(250);
|
||||||
|
}
|
||||||
|
throw new Error(`Installed npm update never reached ${status}`);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const install = (archive) => run('npm', ['install', '--global', '--prefix', prefix, '--force', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), archive], { cwd: temp, env, timeout: 600000 });
|
||||||
|
const start = () => run(path.join(prefix, 'bin/qinglong'), ['reload'], { cwd: temp, env, timeout: 180000 });
|
||||||
|
const stop = () => run(pm2, ['delete', 'ecosystem.config.js'], { cwd: root, env });
|
||||||
|
console.log(`Installing actual npm archive with production lifecycle scripts on ${process.version}`);
|
||||||
|
await install(input);
|
||||||
|
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
||||||
|
root = path.join(globalRoot, '@whyour/qinglong');
|
||||||
|
env.QL_DIR = root;
|
||||||
|
// Use a subprocess: cached candidate modules must not mask missing files
|
||||||
|
// after old-package installation and the real npm overwrite below.
|
||||||
|
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
||||||
|
env.QL_DATA_DIR = path.join(temp, 'fresh/data');
|
||||||
|
await fs.mkdir(env.QL_DATA_DIR, { recursive: true });
|
||||||
|
console.log('Starting the freshly installed npm package');
|
||||||
|
await start();
|
||||||
|
const fresh = await healthy();
|
||||||
|
assert.equal(fresh.build.sourceCommit, expected.sourceCommit);
|
||||||
|
assert.equal(fresh.services.grpc, true);
|
||||||
|
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
||||||
|
await stop();
|
||||||
|
await fs.rm(path.join(temp, 'fresh'), { recursive: true, force: true });
|
||||||
|
env.QL_DATA_DIR = data;
|
||||||
|
console.log('Installing the integrity-pinned published legacy npm package');
|
||||||
|
const legacy = await legacyArchive(temp, env);
|
||||||
|
await install(legacy.archive);
|
||||||
|
assert.equal((await read(path.join(root, 'package.json'))).version, legacy.version);
|
||||||
|
await assert.rejects(fs.access(path.join(root, 'cli/src/internal/maintenance/platformBundle.cjs')), { code: 'ENOENT' });
|
||||||
|
const legacyBackendSha = hash(await fs.readFile(path.join(root, 'static/build/app.js')));
|
||||||
|
await start();
|
||||||
|
await healthy();
|
||||||
|
const credentials = { username: 'npm_package_verification', password: `Test_${randomUUID()}!` };
|
||||||
|
await api('user/init', 'PUT', credentials, true);
|
||||||
|
token = (await api('user/login', 'POST', credentials, true)).token;
|
||||||
|
const nonce = randomUUID();
|
||||||
|
await api('envs', 'POST', [{ name: 'NPM_PACKAGE_PRESERVE', value: nonce }]);
|
||||||
|
const script = path.join(data, 'scripts/npm-package-check.js');
|
||||||
|
await fs.writeFile(script, `console.log(${JSON.stringify(nonce)});\n`);
|
||||||
|
const cron = await api('crons', 'POST', { name: 'npm package retained task', command: `node ${script}`, schedule: '0 0 1 1 *' });
|
||||||
|
const userConfig = path.join(data, 'config/config.sh');
|
||||||
|
await fs.appendFile(userConfig, `\n# retained npm package setting ${nonce}\n`);
|
||||||
|
const userConfigSha = hash(await fs.readFile(userConfig));
|
||||||
|
const preservedData = async () => {
|
||||||
|
assert.equal((await api('user')).username, credentials.username);
|
||||||
|
assert.ok((await api('envs')).some((x) => x.name === 'NPM_PACKAGE_PRESERVE' && x.value === nonce));
|
||||||
|
assert.equal((await api(`crons/${cron.id}`)).command, cron.command);
|
||||||
|
assert.equal(hash(await fs.readFile(userConfig)), userConfigSha);
|
||||||
|
assert.equal(await fs.readFile(script, 'utf8'), `console.log(${JSON.stringify(nonce)});\n`);
|
||||||
|
};
|
||||||
|
await preservedData();
|
||||||
|
console.log('Stopping legacy service, backing up its .env and overwriting with the new npm archive');
|
||||||
|
await stop();
|
||||||
|
// npm replaces its entire package directory. Back up a custom root .env
|
||||||
|
// explicitly, as required by the documented package-manager upgrade flow.
|
||||||
|
await fs.appendFile(path.join(root, '.env'), `\n# retained custom npm environment ${nonce}\n`);
|
||||||
|
const savedEnv = await fs.readFile(path.join(root, '.env'));
|
||||||
|
await install(input);
|
||||||
|
await fs.writeFile(path.join(root, '.env'), savedEnv);
|
||||||
|
const configSha = hash(savedEnv);
|
||||||
|
const local = createRequire(path.join(root, 'package.json'));
|
||||||
|
const artifacts = local('./shell/upgrade.cjs');
|
||||||
|
const build = await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
||||||
|
assert.equal(build.sourceCommit, expected.sourceCommit);
|
||||||
|
assert.notEqual(hash(await fs.readFile(path.join(root, 'static/build/app.js'))), legacyBackendSha);
|
||||||
|
assert.equal(hash(await fs.readFile(path.join(root, 'pnpm-lock.yaml'))), build.lockfileSha256);
|
||||||
|
const fixture = await platformFixture(root, process.env.QL_PANEL_TEST_BUNDLE_DIR, expected.sourceCommit);
|
||||||
|
let badChecksum = true, requests = 0;
|
||||||
|
fixtureServer = http.createServer((request, response) => {
|
||||||
|
requests++;
|
||||||
|
if (request.url === '/latest.json') {
|
||||||
|
response.setHeader('Content-Type', 'application/json');
|
||||||
|
response.end(JSON.stringify({ format: 2, sourceCommit: fixture.sourceCommit, packages: { [fixture.target]: { size: fixture.size, sha256: badChecksum ? '0'.repeat(64) : fixture.sha256, url: `http://127.0.0.1:${fixtureServer.address().port}/bundle.tar.gz` } } }));
|
||||||
|
} else if (request.url === '/bundle.tar.gz') {
|
||||||
|
response.setHeader('Content-Length', fixture.size);
|
||||||
|
sync.createReadStream(fixture.archive).pipe(response);
|
||||||
|
} else { response.statusCode = 404; response.end(); }
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => fixtureServer.listen(0, '127.0.0.1', resolve));
|
||||||
|
env.QL_UPGRADE_INDEX_URL = `http://127.0.0.1:${fixtureServer.address().port}/latest.json`;
|
||||||
|
console.log('Starting the overwritten npm package against legacy user data');
|
||||||
|
await start();
|
||||||
|
const original = await healthy();
|
||||||
|
assert.equal(original.build.sourceCommit, build.sourceCommit);
|
||||||
|
assert.equal(original.services.grpc, true);
|
||||||
|
token = (await api('user/login', 'POST', credentials, true)).token;
|
||||||
|
const preserved = async () => {
|
||||||
|
await preservedData();
|
||||||
|
assert.equal(hash(await fs.readFile(path.join(root, '.env'))), configSha);
|
||||||
|
};
|
||||||
|
await preserved();
|
||||||
|
console.log('Testing a rejected download through the installed panel API');
|
||||||
|
const rejected = await api('system/update', 'PUT');
|
||||||
|
assert.match((await job(rejected.id, 'failed')).error, /checksum|校验/i);
|
||||||
|
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
||||||
|
await preserved();
|
||||||
|
badChecksum = false;
|
||||||
|
console.log('Testing installed panel download, prestart and explicit application');
|
||||||
|
const accepted = await api('system/update', 'PUT');
|
||||||
|
const ready = await job(accepted.id, 'ready');
|
||||||
|
assert.equal((await healthy()).build.instanceId, original.build.instanceId);
|
||||||
|
const pointer = await read(path.join(root, '.tmp/upgrade-ready-develop.json'));
|
||||||
|
const receipt = await read(path.join(root, '.tmp', pointer.directory, 'prestart.json'));
|
||||||
|
assert.ok(receipt.completedAt <= ready.updatedAt, 'Readiness was recorded before backend prestart');
|
||||||
|
const applying = await api('update/system', 'PUT');
|
||||||
|
await job(applying.id, 'completed');
|
||||||
|
const afterOnline = await healthy();
|
||||||
|
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
||||||
|
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
||||||
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||||
|
assert.match(await fs.realpath(path.join(root, 'node_modules/sqlite3')), /[/\\]node_modules[/\\]\.pnpm[/\\]/);
|
||||||
|
await fs.access(path.join(root, 'scripts/build-upgrade-bundle.cjs'));
|
||||||
|
await artifacts.verifyUpgrade(root, path.join(root, 'static'));
|
||||||
|
assert.match(await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text()), /<html/i);
|
||||||
|
await preserved();
|
||||||
|
console.log('Testing offline installer and Bash reload from the installed npm package');
|
||||||
|
const requestsBeforeOffline = requests;
|
||||||
|
await run(process.execPath, [path.join(root, 'shell/install-bundle.cjs'), '--root', root, '--file', fixture.archive, '--download-only'], { cwd: temp, env, timeout: 240000 });
|
||||||
|
assert.equal(requests, requestsBeforeOffline, 'Offline preparation contacted the index');
|
||||||
|
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
||||||
|
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
||||||
|
const afterOffline = await healthy();
|
||||||
|
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
||||||
|
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
||||||
|
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||||
|
assert.equal(hash(await fs.readFile(process.execPath)), nodeSha);
|
||||||
|
assert.equal((await api('system/update-status')).status, 'completed');
|
||||||
|
await preserved();
|
||||||
|
await api('crons/run', 'PUT', [cron.id]);
|
||||||
|
let taskExecuted = false;
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const log = String(await api(`crons/${cron.id}/log`));
|
||||||
|
if (log.includes(nonce) && /完成|执行结束|finished|completed/i.test(log)) { taskExecuted = true; break; }
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
assert.ok(taskExecuted, 'Retained task did not execute after npm package upgrades');
|
||||||
|
const result = {
|
||||||
|
node: process.version, platform: fixture.target,
|
||||||
|
archiveSha256: hash(await fs.readFile(input)), sourceCommit: build.sourceCommit,
|
||||||
|
installedProductionDependencies: true, restoredLockVerified: true,
|
||||||
|
legacyNpmVersion: legacy.version, legacyNpmIntegrity: legacy.integrity,
|
||||||
|
legacyNpmOverwriteInstalled: true, legacyExternalDataPreserved: true,
|
||||||
|
customRootEnvironmentRestoredFromExplicitBackup: true,
|
||||||
|
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
||||||
|
migratedFromNpmToOfficialPnpmLayout: true,
|
||||||
|
existingNodePreserved: true,
|
||||||
|
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
||||||
|
prestartBeforeReady: true, onlineActualRestart: true,
|
||||||
|
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
||||||
|
accountEnvironmentConfigurationAndScriptsPreserved: true, retainedTaskExecuted: true,
|
||||||
|
initialBuild: original.build, afterOnline: afterOnline.build, afterOffline: afterOffline.build,
|
||||||
|
};
|
||||||
|
if (output) {
|
||||||
|
await fs.mkdir(output, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(output, `verification-${process.version}.json`), JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (root) {
|
||||||
|
for (const file of ['qinglong-error.log', 'qinglong-out.log']) {
|
||||||
|
const text = await fs.readFile(path.join(env.PM2_HOME, 'logs', file), 'utf8').catch(() => '');
|
||||||
|
if (text) console.error(text.slice(-6000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (fixtureServer) {
|
||||||
|
fixtureServer.closeAllConnections();
|
||||||
|
await new Promise((resolve) => fixtureServer.close(resolve));
|
||||||
|
}
|
||||||
|
await run(pm2, ['kill'], { env, cwd: temp }).catch(() => {});
|
||||||
|
await run('nginx', ['-s', 'quit'], { env, cwd: temp }).catch(() => {});
|
||||||
|
await fs.rm(temp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { checkPackedFiles, packPanel, verifyPanel };
|
||||||
|
if (require.main === module) {
|
||||||
|
const [action, input, output] = process.argv.slice(2);
|
||||||
|
const operation = action === 'pack' && input
|
||||||
|
? packPanel(path.resolve(input))
|
||||||
|
: action === 'verify' && input
|
||||||
|
? verifyPanel(path.resolve(input), output && path.resolve(output))
|
||||||
|
: Promise.reject(new Error('Usage: node scripts/verify-panel-package.cjs pack OUTPUT | verify ARCHIVE_OR_DIRECTORY [RESULT_DIRECTORY]'));
|
||||||
|
operation.catch((error) => { console.error(error); process.exitCode = 1; });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createHash, randomUUID } = require('node:crypto');
|
||||||
|
|
||||||
|
function restorePackageLock(root = path.resolve(__dirname, '..')) {
|
||||||
|
const copy = path.join(root, 'shell/upgrade-lock.yaml');
|
||||||
|
const destination = path.join(root, 'pnpm-lock.yaml');
|
||||||
|
if (!fs.existsSync(copy)) {
|
||||||
|
// Source checkouts already have the canonical lock; no built artifacts yet.
|
||||||
|
if (fs.existsSync(destination)) return;
|
||||||
|
throw new Error('Missing npm package upgrade lockfile');
|
||||||
|
}
|
||||||
|
const bytes = fs.readFileSync(copy);
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(root, 'static/build-info.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
manifest.dirty !== false ||
|
||||||
|
manifest.lockfileSha256 !== createHash('sha256').update(bytes).digest('hex')
|
||||||
|
)
|
||||||
|
throw new Error('npm package lockfile does not match its build manifest');
|
||||||
|
const temporary = path.join(root, `.upgrade-lock-${randomUUID()}`);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o644 });
|
||||||
|
fs.renameSync(temporary, destination);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporary, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { restorePackageLock };
|
||||||
|
if (require.main === module) restorePackageLock();
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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 { createHash } = require('node:crypto');
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
const { promisify } = require('node:util');
|
||||||
|
const execute = promisify(execFile);
|
||||||
|
const { checkPackedFiles } = require('../../scripts/verify-panel-package.cjs');
|
||||||
|
const { restorePackageLock } = require('../../shell/install-package.cjs');
|
||||||
|
|
||||||
|
async function packageFixture(t) {
|
||||||
|
const temp = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-contract-')));
|
||||||
|
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
||||||
|
const root = path.join(temp, 'source'), output = path.join(temp, 'archives');
|
||||||
|
await fs.mkdir(root);
|
||||||
|
await fs.mkdir(output);
|
||||||
|
const copy = async (name) => {
|
||||||
|
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
||||||
|
await fs.copyFile(path.resolve(name), path.join(root, name));
|
||||||
|
};
|
||||||
|
for (const name of ['.gitignore', '.npmignore', 'docker/verify-build.cjs', 'docker/build-manifest.cjs', '.env.example', 'ecosystem.config.js', 'version.yaml', 'README.md', 'LICENSE', 'scripts/prepare-npm-package.cjs', 'back/protos/api.proto']) await copy(name);
|
||||||
|
for (const directory of ['shell', 'sample', 'cli/src/internal/maintenance']) {
|
||||||
|
await fs.cp(directory, path.join(root, directory), {
|
||||||
|
recursive: true,
|
||||||
|
filter: (source) => !/[/\\](?:__pycache__|node_modules)(?:[/\\]|$)|[/\\]preload[/\\](?:env\.|notify\.|__ql_notify__\.|lang_env\.sh)/.test(source),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
|
||||||
|
// The structural test stays offline; full production installation and service
|
||||||
|
// upgrades run against the unmodified package in verify-panel-package.cjs.
|
||||||
|
metadata.dependencies = {};
|
||||||
|
metadata.devDependencies = {};
|
||||||
|
await fs.writeFile(path.join(root, 'package.json'), JSON.stringify(metadata));
|
||||||
|
const lock = 'lockfileVersion: 6.0\n';
|
||||||
|
await fs.writeFile(path.join(root, 'pnpm-lock.yaml'), lock);
|
||||||
|
const files = {};
|
||||||
|
for (const name of ['build/app.js', 'build/token.js', 'build/bootstrap/upgradeProbe.js', 'dist/index.html', 'build/app.js.map', 'dist/app.js.gz']) {
|
||||||
|
const bytes = name.endsWith('.html') ? '<html>pack fixture</html>' : '{}';
|
||||||
|
await fs.mkdir(path.dirname(path.join(root, 'static', name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, 'static', name), bytes);
|
||||||
|
files[name] = createHash('sha256').update(bytes).digest('hex');
|
||||||
|
}
|
||||||
|
for (const name of ['.env', 'output/private.json', 'data/config/auth.json', 'shell/preload/env.js', 'shell/preload/__ql_notify__.js', 'shell/preload/__pycache__/env.pyc', 'cli/dist/npm/ql.js']) {
|
||||||
|
await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(root, name), 'DO_NOT_PUBLISH');
|
||||||
|
}
|
||||||
|
const env = { ...process.env, HOME: temp, NODE_ENV: 'production' };
|
||||||
|
await execute('git', ['init', '-q'], { cwd: root, env });
|
||||||
|
await execute('git', ['add', '-A'], { cwd: root, env });
|
||||||
|
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'fixture'], { cwd: root, env });
|
||||||
|
await fs.writeFile(path.join(root, 'static/build-info.json'), JSON.stringify({
|
||||||
|
version: 1, dirty: false, sourceCommit: (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim(), files,
|
||||||
|
lockfileSha256: createHash('sha256').update(lock).digest('hex'),
|
||||||
|
}));
|
||||||
|
const pack = () => execute('npm', ['pack', '--json', '--cache', path.join(temp, 'cache'), '--pack-destination', output], { cwd: root, env, timeout: 60000, maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
return { root, output, temp, env, lock, files, pack };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real npm pack excludes local state and its installed lifecycle restores verified runtime files', async (t) => {
|
||||||
|
const { root, output, temp, env, lock, files, pack } = await packageFixture(t);
|
||||||
|
const packed = await pack();
|
||||||
|
const [archive] = JSON.parse(packed.stdout);
|
||||||
|
checkPackedFiles(archive.files);
|
||||||
|
const names = new Set(archive.files.map((file) => file.path));
|
||||||
|
assert.ok(names.has('shell/upgrade-lock.yaml'));
|
||||||
|
for (const name of Object.keys(files)) assert.ok(names.has('static/' + name));
|
||||||
|
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
|
||||||
|
const prefix = path.join(temp, 'installed');
|
||||||
|
await execute('npm', ['install', '--offline', '--omit=dev', '--no-audit', '--no-fund', '--cache', path.join(temp, 'cache'), '--prefix', prefix, path.join(output, archive.filename)], { cwd: temp, env, timeout: 60000 });
|
||||||
|
const installed = path.join(prefix, 'node_modules/@whyour/qinglong');
|
||||||
|
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
|
||||||
|
assert.equal(typeof require(path.join(installed, 'shell/upgrade.cjs')).stageUpgrade, 'function');
|
||||||
|
assert.ok((await fs.stat(path.join(installed, 'shell/update.sh'))).mode & 0o111);
|
||||||
|
for (const name of ['ql', 'task', 'qinglong']) await fs.access(path.join(prefix, 'node_modules/.bin', name));
|
||||||
|
await fs.writeFile(path.join(installed, 'shell/upgrade-lock.yaml'), 'tampered');
|
||||||
|
assert.throws(() => restorePackageLock(installed), /does not match/);
|
||||||
|
assert.equal(await fs.readFile(path.join(installed, 'pnpm-lock.yaml'), 'utf8'), lock);
|
||||||
|
assert.ok(!(await fs.readdir(installed)).some((name) => name.startsWith('.upgrade-lock-')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('postinstall still supports minimal Docker dependency stages before shell files are copied', async (t) => {
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-builder-'));
|
||||||
|
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
||||||
|
const metadata = JSON.parse(await fs.readFile('package.json', 'utf8'));
|
||||||
|
await fs.writeFile(path.join(temp, 'package.json'), JSON.stringify({ scripts: { postinstall: metadata.scripts.postinstall } }));
|
||||||
|
await fs.writeFile(path.join(temp, 'pnpm-lock.yaml'), 'source lock');
|
||||||
|
await execute('npm', ['run', 'postinstall', '--cache', path.join(temp, 'cache')], { cwd: temp, timeout: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing npm runtime lockfile fails instead of claiming an installed package is ready', async (t) => {
|
||||||
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-npm-lock-missing-'));
|
||||||
|
t.after(() => fs.rm(temp, { recursive: true, force: true }));
|
||||||
|
assert.throws(() => restorePackageLock(temp), /Missing npm package upgrade lockfile/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('npm publication consumes the exact archive whose isolated installation passed', () => {
|
||||||
|
const yaml = require('js-yaml');
|
||||||
|
const sync = require('node:fs');
|
||||||
|
const workflow = yaml.load(sync.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'));
|
||||||
|
const verify = workflow.jobs['verify-npm-package'];
|
||||||
|
assert.deepEqual(verify.needs, ['build-static', 'build-npm-migration-bundle']);
|
||||||
|
assert.ok(verify.steps.some((step) => step.with?.name === 'npm-migration-bundle-${{ github.sha }}'));
|
||||||
|
const migration = workflow.jobs['build-npm-migration-bundle'];
|
||||||
|
assert.equal(migration.needs, 'build-debian');
|
||||||
|
assert.ok(migration.steps.some((step) => /Dockerfile.upgrade-bundle/.test(step.run || '')));
|
||||||
|
assert.ok(!migration.steps.some((step) => /publish-upgrade-bundles/.test(step.run || '')));
|
||||||
|
assert.deepEqual(verify.strategy.matrix.node, ['20.18.1', '24']);
|
||||||
|
assert.match(verify.container, /node:.*bookworm/);
|
||||||
|
const tools = verify.steps.find((step) => step.name === 'Install isolated service test tools');
|
||||||
|
assert.match(tools.run, /npm install --global --prefix \/usr\/local.*pm2@6\.0\.14/);
|
||||||
|
const service = verify.steps.find((step) => step.name === 'Install and upgrade the exact npm archive');
|
||||||
|
assert.equal(service.env.QL_PANEL_TEST_PM2, '/usr/local/bin/pm2');
|
||||||
|
assert.ok(workflow.jobs.publish.needs.includes('verify-npm-package'));
|
||||||
|
const download = workflow.jobs.publish.steps.find((step) => step.uses?.startsWith('actions/download-artifact@'));
|
||||||
|
assert.equal(download.with.name, 'qinglong-npm-${{ github.sha }}');
|
||||||
|
const publication = workflow.jobs.publish.steps.find((step) => step.name === 'Publish npm package with OIDC');
|
||||||
|
assert.match(publication.run, /npm publish "\$archive" --ignore-scripts/);
|
||||||
|
assert.ok(!workflow.jobs.publish.steps.some((step) => /pnpm build/.test(step.run || '')));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of ['dirty-source', 'stale-commit', 'dirty-build', 'changed-lock', 'changed-output', 'missing-output', 'extra-output']) {
|
||||||
|
test(`native npm pack rejects ${scenario} before producing an archive`, async (t) => {
|
||||||
|
const { root, output, pack } = await packageFixture(t);
|
||||||
|
const manifestFile = path.join(root, 'static/build-info.json');
|
||||||
|
const manifest = JSON.parse(await fs.readFile(manifestFile, 'utf8'));
|
||||||
|
if (scenario === 'dirty-source') await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# uncommitted change\n');
|
||||||
|
if (scenario === 'stale-commit') {
|
||||||
|
await fs.appendFile(path.join(root, 'shell/start.sh'), '\n# committed change after build\n');
|
||||||
|
await execute('git', ['add', '-A'], { cwd: root });
|
||||||
|
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new source'], { cwd: root });
|
||||||
|
}
|
||||||
|
if (scenario === 'dirty-build') { manifest.dirty = true; await fs.writeFile(manifestFile, JSON.stringify(manifest)); }
|
||||||
|
if (scenario === 'changed-lock') {
|
||||||
|
await fs.appendFile(path.join(root, 'pnpm-lock.yaml'), '# changed lock\n');
|
||||||
|
await execute('git', ['add', '-A'], { cwd: root });
|
||||||
|
await execute('git', ['-c', 'user.name=Package Test', '-c', 'user.email=test@example.invalid', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'new lock'], { cwd: root });
|
||||||
|
manifest.sourceCommit = (await execute('git', ['rev-parse', 'HEAD'], { cwd: root })).stdout.trim();
|
||||||
|
await fs.writeFile(manifestFile, JSON.stringify(manifest));
|
||||||
|
}
|
||||||
|
if (scenario === 'changed-output') await fs.appendFile(path.join(root, 'static/build/app.js'), 'changed');
|
||||||
|
if (scenario === 'missing-output') await fs.rm(path.join(root, 'static/build/app.js'));
|
||||||
|
if (scenario === 'extra-output') await fs.writeFile(path.join(root, 'static/build/leftover.js'), 'old');
|
||||||
|
await assert.rejects(pack(), (error) => {
|
||||||
|
assert.match(error.stderr, /clean source|do not match|checksum mismatch|file set mismatch/i);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.deepEqual(await fs.readdir(output), []);
|
||||||
|
await assert.rejects(fs.access(path.join(root, 'shell/upgrade-lock.yaml')), { code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,14 +36,23 @@ for (const [scenario, expectedStatus, published] of [
|
|||||||
fs.mkdirSync(bin);
|
fs.mkdirSync(bin);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(root, 'package.json'),
|
path.join(root, 'package.json'),
|
||||||
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }),
|
JSON.stringify({ name: '@fixture/unrelated-source', version: '0.0.0' }),
|
||||||
);
|
);
|
||||||
|
fs.mkdirSync(path.join(root, 'package'));
|
||||||
|
fs.mkdirSync(path.join(root, 'npm-panel'));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, 'package/package.json'),
|
||||||
|
JSON.stringify({ name: '@whyour/qinglong', version: '2.22.0' }),
|
||||||
|
);
|
||||||
|
const packed = spawnSync('tar', ['-czf', 'npm-panel/verified.tgz', 'package'], { cwd: root });
|
||||||
|
assert.equal(packed.status, 0, String(packed.stderr));
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(bin, 'npm'),
|
path.join(bin, 'npm'),
|
||||||
`#!/usr/bin/env node
|
`#!/usr/bin/env node
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); }
|
if (process.argv[2] === 'publish') { fs.writeFileSync('published', JSON.stringify(process.argv.slice(3))); process.exit(0); }
|
||||||
if (process.argv[2] !== 'view') process.exit(99);
|
if (process.argv[2] !== 'view') process.exit(99);
|
||||||
|
if (process.argv[3] !== '@whyour/qinglong@2.22.0') process.exit(98);
|
||||||
switch (process.env.QL_PUBLISH_SCENARIO) {
|
switch (process.env.QL_PUBLISH_SCENARIO) {
|
||||||
case 'existing': console.log(JSON.stringify('2.22.0')); break;
|
case 'existing': console.log(JSON.stringify('2.22.0')); break;
|
||||||
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
|
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
|
||||||
@@ -66,5 +75,9 @@ case 'network': console.log('upstream unavailable'); process.exit(1);
|
|||||||
});
|
});
|
||||||
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
|
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
|
||||||
assert.equal(fs.existsSync(path.join(root, 'published')), published);
|
assert.equal(fs.existsSync(path.join(root, 'published')), published);
|
||||||
|
if (published)
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'published'), 'utf8')), [
|
||||||
|
'npm-panel/verified.tgz', '--ignore-scripts', '--access', 'public', '--registry=https://registry.npmjs.org',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user