feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)

* feat(cli): add unified Commander CLI for QingLong 2.x

* fix(cli): publish via npm and address security review feedback

* ci(cli): package npm artifacts and remove evaluation collateral

* test(cli): use a fixed shell fixture for log retention

* refactor(cli): separate remote npm client from panel tools

* feat(cli): cover active panel OpenAPI resources

* docs(cli): unify authentication and skill guidance

* refactor(cli): isolate internal commands and generate Commander help

* refactor(cli): organize remote and internal modules by responsibility

* ci(cli): publish verified npm archives from master

* fix(cli): publish under the whyour npm scope

* ci: use npm trusted publishing for both packages

* docs: introduce the published CLI on the project homepage

* fix(cli): preserve server log truncation and correct login hints

* fix(cli): accept dashboard record request bodies

* fix(cli): preserve stdin for local task execution

* fix(cli): resolve task executables after changing directory

* fix(cli): preserve shell function tasks and sanitize test failures

* fix(cli): preserve shell hook state and resolve workdir after hooks

* fix(cli): preserve cleanup across shared shell task timeouts

* fix(cli): isolate shell control descriptors and reap timed-out descendants
This commit is contained in:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
# Test runtime only: never used as the CLI's distribution or panel image.
FROM node:22.12.0-bookworm-slim@sha256:35531c52ce27b6575d69755c73e65d4468dba93a25644eed56dc12879cae9213
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates curl git jq perl procps python3 unzip zip \
&& rm -rf /var/lib/apt/lists/*
# Match the repository's current TS execution fixture; no host node_modules mount.
RUN npm install -g --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2 \
&& npm cache clean --force
ENV NODE_PATH=/usr/local/lib/node_modules
WORKDIR /workspace
CMD ["sh", "-c", "node cli/scripts/test.cjs && node cli/scripts/verify-package.cjs"]
+8
View File
@@ -0,0 +1,8 @@
# Test runtime only; retains the production Alpine coreutils/Bash tool choices.
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
RUN apk add --no-cache bash ca-certificates coreutils curl git git-daemon jq openssl perl procps python3 unzip zip
RUN npm install -g --ignore-scripts --no-audit --no-fund ts-node@10.9.2 typescript@5.2.2 \
&& npm cache clean --force
ENV NODE_PATH=/usr/local/lib/node_modules
WORKDIR /workspace
CMD ["sh", "-c", "node cli/scripts/test.cjs && node cli/scripts/verify-package.cjs"]
+8
View File
@@ -0,0 +1,8 @@
FROM qinglong-cli-test:node22-debian
RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d \
&& chmod +x /usr/sbin/policy-rc.d \
&& apt-get update && apt-get install -y --no-install-recommends nginx \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts --no-audit --no-fund pm2@5.4.3 \
&& npm cache clean --force
CMD ["node", "--test", "cli/test/linux/services.test.cjs"]
+3
View File
@@ -0,0 +1,3 @@
# Disposable SSH transport fixture only; never a panel distribution image.
FROM qinglong-cli-test:node24-alpine
RUN apk add --no-cache openssh
+164
View File
@@ -0,0 +1,164 @@
# Linux CLI regression runtime
Build the CLI on the host first (`npm run build:cli`), then build this test-only image:
```sh
docker build -t qinglong-cli-test:node22-debian cli/test/linux
docker run --rm --network none \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
--mount type=bind,src="$(pwd)/shell",dst=/workspace/shell,readonly \
--mount type=bind,src="$(pwd)/back/api",dst=/workspace/back/api,readonly \
--workdir /workspace qinglong-cli-test:node22-debian
```
The image supplies Linux interpreters, Git and archive tools. It does not mount host node_modules, credentials, the Docker socket or production data. Tests use temporary installations and loopback API fixtures; the runtime has no external network. Package installation verification runs offline after the test suite. No installed ql/task command is replaced. The original Shell is mounted read-only for differential tests.
Image construction needs network access. If Docker injects a stale proxy, override it for this build only with empty HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and their lowercase build arguments; do not change global proxy settings as part of the test.
The Node base image digest and TypeScript interpreter versions are fixed. Debian package revisions follow the Bookworm repositories at build time. Passing this suite does not prove real panel authentication/database persistence, nginx/PM2 deployment, or compatibility of all user scripts.
For Alpine/musl, use the second pinned base and the same read-only runtime mounts:
```sh
docker build -f cli/test/linux/Dockerfile.alpine -t qinglong-cli-test:node24-alpine cli/test/linux
docker run --rm --network none \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
--mount type=bind,src="$(pwd)/shell",dst=/workspace/shell,readonly \
--mount type=bind,src="$(pwd)/back/api",dst=/workspace/back/api,readonly \
--workdir /workspace qinglong-cli-test:node24-alpine
```
This includes Bash/coreutils, as does the panel's Alpine Dockerfile; it is not a bare BusyBox compatibility claim. Python comes from the pinned base's Alpine repository, and package patch revisions remain build-time dependent.
## Real service manager integration
After building the Debian test image:
```sh
docker build -f cli/test/linux/Dockerfile.services -t qinglong-cli-test:services cli/test/linux
docker run --rm --network none \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
--workdir /workspace qinglong-cli-test:services
```
This separate test installs nginx and PM2 5.4.3 in the image. It invokes bootstrap reload twice with an isolated PM2_HOME and nginx config, checks proxy responses/config replacement/backend PID change, and verifies stopPanel removes the PM2 application. Cleanup quits nginx and kills the test PM2 daemon before the disposable container is removed. Ports 5801/5802 remain container-local. The backend is a minimal HTTP fixture, not a real QingLong application/database. No OS boot integration (`pm2 startup`) or first-time online dependency provisioning is claimed by this test.
## Released 2.x panel integration
`panel.cjs` is for a fresh disposable official panel only. It refuses initialized instances, generates random temporary owner/application credentials without printing them, exercises API login/status/subscription reads and a scheduled task run, then invokes the new local task engine against the real token generator and verifies persisted log_path/execution time. It does not replace installed Shell entrypoints. The fixture script exits 7 in the local-runner step.
Run the official `whyour/qinglong:2.20.1@sha256:4e96d821494cfbeddd29f5a46dfb006a5a64f5639e0b8665816fcf55f39a85ea` image with `--network none`, no published ports, and this CLI directory mounted read-only at `/candidate/cli`. Clear inherited proxy variables for the test container. After its startup log reports readiness, execute:
```sh
docker exec -e QL_PANEL_INTEGRATION=1 <temporary-container-id> node /candidate/cli/test/linux/panel.cjs
```
Always remove that temporary container and its anonymous volume with `docker rm -fv <temporary-container-id>` after the run, including failures. Never point this test at an existing initialized panel. The version is explicit because the current workspace's backend build also contains independent 3.0 changes; this gate tests the public 2.x compatibility contract without pulling 3.0 work into the migration.
## Interrupted replacement with real services
Using the services image above, run:
```sh
docker run --rm --network none \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
--workdir /workspace qinglong-cli-test:services \
node --test cli/test/linux/upgrade.test.cjs
```
This test covers direct Node and PM2 separately. It starts the old HTTP fixture, invokes replaceAndReload in a child process, waits until the new HTTP fixture responds, sends SIGTERM to the upgrade child, then checks exit 143, old file/HTTP restoration, disappearance of the new PID, and backup cleanup. The child fixture inserts a deterministic wait after real startup so the signal cannot race past the rollback boundary. Test services and PM2_HOME are temporary; port 5811 is container-local. It exercises the real service functions and filesystem replacement but not QingLong database migrations, archive downloads, or interruption during a package install.
The optional `QL_PANEL_ENTRYPOINTS=1` environment flag on the same fresh-panel command enables a test-only reversible switch of the official release's `~/bin/task` and `~/bin/ql` symlinks. Original Shell targets remain on disk, and the links are restored in finally. It additionally schedules `ql extra` and checks persisted log metadata. The corrected gate passed on official 2.20.1 after legacy status-field selection and scheduler log-path reuse were added; retain the run output as evaluation evidence. It covers task and ql extra scheduling, not every legacy command.
With entry switching enabled, panel.cjs also runs panel-subscription.cjs: a loopback file subscription downloads through the new worker, creates one task with its subscription ID, then executes that task through the panel and checks its output. The official 2.20.1 gate passed on 2026-09-25. Notification channels are intentionally unconfigured; their rejection must produce a diagnostic without failing an otherwise completed synchronization. Repository subscriptions are outside this particular gate.
The switched-entrypoint fixture also includes panel-repository.cjs. It creates a local file:// Git repository with a main and selected branch, include/exclude patterns, a shared dependency, and two revisions. Assertions check the selected branch, exclusion, dependency execution, stable ID for an unchanged task, addition/removal after the second pull, and removal of the obsolete script. The source is local because the official image lacks git-daemon. This gate does not establish network Git transport, proxies or private repository authentication.
The switched-entrypoint gate also runs panel-account.cjs at the end. It changes only the freshly generated test owner's username/password through ql, verifies new login credentials, activates login retry/second-factor requirements via the test owner's API, and verifies resetlet/resettfa restore access. It never prints credentials and must only run inside the disposable panel guarded by panel.cjs. The account and its data volume are removed with the container.
The expanded switched-entrypoint gate now includes panel-operator.cjs for real log retention and full-panel service reload. Without the candidate loader, the original 2.20.1 startup loader overwrites temporary Node wrappers with Shell links. Use the candidate-loader gate below to verify persistent entry selection.
### Persistent entrypoint selection gate
The loader integration regression can be run on the development host with `node --test cli/test/linux/entrypoints.test.cjs`; it needs the repository TypeScript dev dependency and reads the actual back/loaders/deps.ts. It verifies both commands, exact args/exit codes, paths with spaces, repeated startup selection, invalid/incomplete installations and return to original Shell.
For the real-panel gate, transpile only back/loaders/deps.ts to a temporary CommonJS file using the repository TypeScript transpileModule (CommonJS, esModuleInterop, ES2022). Mount that file read-only as /candidate/deps.js alongside the CLI mount and start the disposable official image with QL_CLI_ROOT=/candidate/cli. Add QL_PANEL_LOADER=1 to the panel.cjs docker exec environment. The guarded fixture backs up the installed compiled loader, copies this single candidate loader, exercises reload, and restores the compiled loader and command links in finally. No full workspace backend/3.0 build is used. The updated gate passed with operatorMaintenance=true on 2026-09-25; without the candidate loader the expected old-entry overwrite remains reproducible.
## Online check/repair gate
Use a fresh disposable official 2.20.1 container with the CLI read-only mount, no published ports/user data, and normal container networking (unlike the offline gates). Clear inherited proxy environment variables. Execute `node /candidate/cli/dist/admin.js check --root /ql --json` with npm_config_registry=https://registry.npmjs.org, npm_config_fetch_retries=0 and npm_config_fetch_timeout=30000, redirecting stdout to /tmp/check-result.json. This performs real package downloads and container-local installs.
For the repair scenario, append a fixture comment to /ql/data/config/config.sh, copy it to /tmp/expected-config.sh, delete /ql/data/config/task_before.sh, and overwrite /ql/data/scripts/sendNotify.js with a fixture string. Run the same check again, redirecting stdout to /tmp/check-repair-result.json. Then execute `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/verify-check.cjs` inside that container. Always remove the container and anonymous volumes afterward. The official image and registry dependencies can change; retain the exact image digest and resulting tool versions with the evidence. Never run this damage fixture on an existing panel.
## First-start container gate
Start a fresh official image with `--entrypoint sleep`, arguments `infinity`, explicit QL_DIR=/ql and QL_DATA_DIR=/ql/data, and the read-only CLI mount. Allow package network access and clear inherited proxy variables as in the online check gate. This bypasses the legacy container entrypoint. Run `node /candidate/cli/dist/admin.js start --root /ql --data-dir /ql/data --no-startup --json > /tmp/start-result.json` inside the container, then `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/verify-start.cjs`. Use the same bounded npm registry environment as online check. Remove the container and anonymous volumes afterward.
--no-startup explicitly skips OS boot registration for containers lacking init, while PM2 save still runs. This does not validate host reboot behavior. The official image already provides Node/Python/panel dependencies; the gate verifies migration of startup orchestration, not provisioning an empty OS.
## Online Bot installation/process gate
Use a fresh official 2.20.1 container with `--entrypoint sleep ... infinity`, normal container networking, cleared proxy variables and the read-only CLI mount. Run `QL_PANEL_INTEGRATION=1 node /candidate/cli/test/linux/bot-install.cjs` inside it. The fixture creates a local Git repository, performs real apk/pip installation, starts two Python test bots in separate data directories, replaces one and verifies configuration/process isolation, then stops/cleans them. It sends no Telegram traffic and uses no bot credentials. Remove the disposable container and anonymous volumes afterward. The dependency fixture pins colorama 0.4.6; OS packages follow the image's configured repositories.
The network repository regression uses a loopback smart-HTTP Git service, random test credentials and a loopback HTTP proxy. It requires `git-http-backend`; Alpine packages it in `git-daemon`, included only in this test image. No external repository or credential is used, and the runtime still works with `--network none`.
The same regression also runs a TLS smart-HTTP service and an actual HTTP CONNECT tunnel. OpenSSL generates a temporary self-signed certificate with an IP SAN; Git receives it through GIT_SSL_CAINFO only for the trusted case. Removing trust must fail, as must HTTP 401/503, while preserving installed scripts and checkout HEAD. The Alpine test image includes the openssl executable for this fixture; no runtime CLI dependency or certificate-verification bypass is introduced.
## Isolated SSH subscription gate
Build `cli/test/linux/Dockerfile.ssh` as `qinglong-cli-test:ssh` after the Alpine test image. Run only in a fresh disposable container:
```sh
docker run --rm --init --network none -e QL_SSH_INTEGRATION=1 \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
qinglong-cli-test:ssh node --test cli/test/linux/ssh-repository.test.cjs
```
This root-only fixture creates a container-local account, host/client keys and repository. It starts sshd on loopback port 22022 with password authentication and forwarding disabled, then verifies both SSH URL and scp-style Git addresses. StrictHostKeyChecking and IdentitiesOnly remain enabled. Wrong identities and mismatched known_hosts must fail without changing scripts or checkout HEAD. The fixture does not mount host SSH configuration or keys. Its temporary authorization files live below /var/lib because sshd StrictModes rejects the world-writable /tmp ancestor. Never run this opt-in fixture directly on a host: account removal is provided by disposable-container deletion.
## Published upgrade archive gate
Run `QL_ARCHIVE_INTEGRATION=1 node --test cli/test/linux/published-archive.test.cjs` with network access and current dist. Set QL_ARCHIVE_MIRROR=gitee to exercise that explicit mirror instead of GitHub. The gate calls stageUpgrade against real master source/static ZIP URLs and validates paths, expected app entry, readiness marker and selected-stage pointer. It records archive hashes and version for reproducibility because master can change.
Dependency installation is intercepted: downloaded source is never executed, and no installed panel is reloaded. The existing package-install and retained-data upgrade gates provide separate evidence for those operations. This gate uses temporary directories, bounds download work with cancellation at 150 seconds and removes staging afterward. External archive availability is a prerequisite; connection failures must not be reported as successful transport validation.
Data reload with a real mount root and a live Node backend has a separate disposable gate:
```sh
docker run --rm --network none --tmpfs /mounted-data -e QL_MOUNT_TEST=1 \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
qinglong-cli-test:node24-alpine node --test cli/test/linux/mounted-data.test.cjs
```
The fixture requires an empty separate filesystem at /mounted-data, never a production data mount. It verifies the mount inode/device remain unchanged, obsolete data is removed, staged data is installed and the restarted backend serves the new data.
## Container startup-hook ownership
```sh
docker run --rm --init --network none --user 65534:65534 \
--mount type=bind,src="$(pwd)/cli",dst=/workspace/cli,readonly \
qinglong-cli-test:node24-alpine node --test \
cli/test/containerRuntime.test.cjs cli/test/linux/container-bot.test.cjs
```
The bot fixture runs the actual detached admin installer and Python launcher against a local module with no Telegram code. OS/pip provisioning commands are fixture stubs; the separate online gate above validates dependency installation. It waits for the installer to exit while Python remains alive, stops the container runtime, and verifies that only its bot exits while another installation's bot stays alive. All fixture processes are cleaned, including on assertion failures. --init reaps adopted orphans.
## Real container crond gate
Start a fresh opt-in 2.20.1 image using the TS container entry with --init, --stop-timeout 30 and QL_SCHEDULER=system. Rebuild the evaluation image with the current CLI and mount only cli/test read-only at /opt/qinglong-cli/test. Do not overlay cli or dist: the source tree lacks image-generated bin links and local tsc output lacks the executable modes assigned during image packaging. No network or published port is required. Run `QL_PANEL_INTEGRATION=1 node /opt/qinglong-cli/test/linux/container-cron.cjs` with docker exec. It creates one minute-scheduled task through the local API, checks the installed system crontab and waits up to ninety seconds for a real minute tick. It never calls the task run API. The task records its process ancestry; the gate requires real crond, the selected Node task entry (verified wrapper or symlink target) and the TS container entry, and reads output through the panel log API. Task and script are removed in a finalizer. Stop the container, inspect its exit code and remove it with its anonymous volume afterward.
## Packaged language preload and service reload gate
Mount only cli/test at /opt/qinglong-cli/test in a fresh packaged 2.20.1 evaluation container. Start it through dist/container.js with QL_PANEL_INTEGRATION=1, --init and a 30-second stop timeout. Run panel.cjs first on the uninitialized panel for application authentication and API task/log checks. Then run `node /opt/qinglong-cli/test/linux/panel-preload.cjs` inside that container; set QL_PANEL_RELOAD_INTEGRATION=1 to additionally invoke the packaged local service reload after creating the first task and verify it survives. The gate schedules JS/MJS/Python through the real API, checks Shell before-hook exports and actual QLAPI, and imports a temporary global ESM exported subpath. It restores the hook and removes tasks/scripts/package in cleanup.
Use the image's ordinary seeded data volume. An empty tmpfs or host bind mount over /ql/data masks image-preinstalled requests under dep_cache/python3; that is not a complete interpreter environment. When explicitly evaluating tmpfs, seed the fixed image's preinstalled dependencies into the live mount and verify `python3 -c 'import requests'` before this gate. The recorded run used an offline copy from the same image, not pip installation or stub notification modules. Docker cp did not materialize writes in the active tmpfs in this environment; extraction by tar inside the running container did. Keep this prerequisite distinct from the separate empty-OS provisioning requirement.
## Real host reboot gate
`host-boot.cjs` runs only as root with QL_HOST_INTEGRATION=1 inside a disposable Alpine/OpenRC or Debian/systemd VM. Prepare a 2.x panel distribution plus the CLI, with Bash/Node/npm/Python/pip prerequisite runtimes. Run the actual `ql-local-cli start --root /ql` and require a successful registration result. Copy the test directory alongside the CLI dist directory, then run `node test/linux/host-boot.cjs before`, reboot the guest, and run the same script with `after` once SSH returns. Never run this on a production host.
The before phase verifies live nginx/crond, creates a minute-scheduled task and saves the kernel boot ID in a private fixture state file. The after phase checks OpenRC or systemd service state, requires a different boot ID, checks the stored task command and waits up to ninety seconds for a task log containing the new boot ID. It does not call the task run API. Success removes the task, script and state. On failure, retain the isolated guest for diagnosis or destroy it; do not cite PM2-only recovery as proof of nginx/cron recovery. The task fixture and script paths are under /ql, and the guest state file is /var/lib/ql-host-fixture.json.
## Remote OpenAPI CRUD gate
`openapi.cjs` requires a separate fresh official panel and `QL_PANEL_INTEGRATION=1`; it refuses initialized panels. Mount cli read-only at /candidate/cli, run the official image without published ports or production data, then execute `docker exec -e QL_PANEL_INTEGRATION=1 <container> node /candidate/cli/test/linux/openapi.cjs`. It creates temporary owner/application credentials without printing them and verifies task/subscription/app/env CRUD, app secret rotation, script/config writes and reads, and log/dependency reads through the npm bundle. It does not execute system updates, data import, real dependency installation or every dashboard/user mutation. Always remove the disposable container and anonymous volumes with `docker rm -fv <container>`.
+111
View File
@@ -0,0 +1,111 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const {
installAndStartBot,
launchBot,
stopBot,
} = require('../../dist/internal/maintenance/bot');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
(async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-online-'));
const foreignRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-bot-other-'));
const source = path.join(root, 'source');
const env = {
...process.env,
PYTHONUNBUFFERED: '1',
PIP_DEFAULT_TIMEOUT: '30',
PIP_RETRIES: '0',
};
const ctx = createContext({ root }, { ...env, BotRepoUrl: source });
const other = createContext({ root: foreignRoot }, env);
const program = (version) =>
`import time, colorama\nprint('${version}:'+colorama.__version__,flush=True)\nwhile True: time.sleep(0.1)\n`;
try {
await fs.mkdir(path.join(source, 'jbot'), { recursive: true });
await fs.mkdir(path.join(source, 'config'));
await fs.writeFile(
path.join(source, 'config/bot.json'),
'{"fixture":"default"}',
);
await fs.writeFile(
path.join(source, 'jbot/requirements.txt'),
'colorama==0.4.6\n',
);
await fs.writeFile(path.join(source, 'jbot/__main__.py'), program('FIRST'));
const git = (...args) =>
execFileSync('git', ['-C', source, ...args], { stdio: 'ignore' });
git('init', '-b', 'main');
git('add', '.');
git(
'-c',
'user.name=fixture',
'-c',
'user.email=fixture@example.invalid',
'commit',
'-m',
'fixture',
);
const first = await installAndStartBot(ctx);
const oldPid = first.result.service.pid;
assert.match(
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
/FIRST:0.4.6/,
);
await fs.mkdir(path.join(other.data, 'jbot'), { recursive: true });
await fs.writeFile(
path.join(other.data, 'jbot/__main__.py'),
program('OTHER'),
);
const otherPid = (await launchBot(other)).pid;
await fs.writeFile(
path.join(ctx.paths.dir_config, 'bot.json'),
'{"fixture":"preserve"}',
);
await fs.writeFile(
path.join(first.repository, 'jbot/__main__.py'),
program('SECOND'),
);
const second = await installAndStartBot(ctx);
assert.notEqual(second.result.service.pid, oldPid);
assert.throws(() => process.kill(oldPid, 0), { code: 'ESRCH' });
process.kill(otherPid, 0);
assert.match(
await fs.readFile(path.join(ctx.paths.dir_log, 'bot/nohup.log'), 'utf8'),
/SECOND:0.4.6/,
);
assert.equal(
await fs.readFile(path.join(ctx.paths.dir_config, 'bot.json'), 'utf8'),
'{"fixture":"preserve"}',
);
await stopBot(ctx);
await new Promise((resolve) => setTimeout(resolve, 100));
assert.throws(() => process.kill(second.result.service.pid, 0), {
code: 'ESRCH',
});
process.kill(otherPid, 0);
console.log(
JSON.stringify({
dependencyInstalled: true,
started: true,
replaced: true,
configurationPreserved: true,
otherInstallationSurvived: true,
stopped: true,
}),
);
} finally {
for (const context of [ctx, other]) {
if (await fs.stat(context.data).catch(() => undefined))
await stopBot(context);
}
await fs.rm(root, { recursive: true, force: true });
await fs.rm(foreignRoot, { recursive: true, force: true });
}
})().catch(() => {
console.error('Bot installation integration test failed; raw subprocess errors are omitted to protect environment credentials.');
process.exitCode = 1;
});
+141
View File
@@ -0,0 +1,141 @@
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 { setTimeout: delay } = require('node:timers/promises');
const { createContext } = require('../../dist/internal/runtime/context');
const { runContainer } = require('../../dist/internal/runtime/containerRuntime');
const { launchStartupHook } = require('../../dist/internal/maintenance/bootstrap');
const { launchBot } = require('../../dist/internal/maintenance/bot');
const live = (pid) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error.code === 'ESRCH') return false;
throw error;
}
};
async function until(check) {
for (let i = 0; i < 200; i++) {
if (await check()) return;
await delay(25);
}
throw new Error('Timed out waiting for fixture process');
}
test(
'container stops its daemonized bot after installer exits and preserves another installation',
{ skip: process.platform !== 'linux', timeout: 20000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-container-bot-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
for (const dir of [
'data',
'sample',
'etc',
'bin',
'data/repo/dockerbot/.git',
'data/repo/dockerbot/jbot',
'data/repo/dockerbot/config',
'other/data/jbot',
'other/data/log',
])
await fs.mkdir(path.join(root, dir), { recursive: true });
for (const name of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.py',
'ql_sample.js',
])
await fs.writeFile(path.join(root, 'sample', name), '\n');
await fs.writeFile(
path.join(root, 'sample/config.sample.sh'),
'AutoStartBot=true\n',
);
// Isolate package provisioning; execute the real installer, Python launcher
// and detached admin entry against a local module that never uses Telegram.
for (const name of ['apk', 'sudo', 'pip3'])
await fs.writeFile(path.join(root, 'bin', name), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
const python =
'import os,time\nfrom pathlib import Path\nPath(os.environ["FIXTURE_PID"]).write_text(str(os.getpid()))\nwhile True: time.sleep(1)\n';
await fs.writeFile(
path.join(root, 'data/repo/dockerbot/jbot/__main__.py'),
python,
);
await fs.writeFile(
path.join(root, 'data/repo/dockerbot/jbot/requirements.txt'),
'',
);
await fs.writeFile(
path.join(root, 'data/repo/dockerbot/config/bot.json'),
'{}',
);
await fs.writeFile(path.join(root, 'other/data/jbot/__main__.py'), python);
const env = {
PATH: `${root}/bin:${process.env.PATH}`,
HOME: root,
QL_OS_TYPE: 'alpine',
QL_SCHEDULER: 'node',
FIXTURE_PID: path.join(root, 'bot.pid'),
};
const context = createContext({ root }, env);
const foreign = createContext(
{ root: path.join(root, 'other') },
{ ...env, FIXTURE_PID: path.join(root, 'foreign.pid') },
);
const foreignBot = await launchBot(foreign);
let ownPid, installerPid;
const controller = new AbortController();
let running;
t.after(async () => {
controller.abort('SIGTERM');
for (const pid of [ownPid, installerPid, foreignBot.pid])
if (pid) {
try {
process.kill(-pid, 'SIGKILL');
} catch (error) {
if (error.code !== 'ESRCH') throw error;
}
}
await running?.catch(() => {});
});
running = runContainer(context, controller.signal, {
systemDirectory: path.join(root, 'etc'),
services: {
start: async () => ({ manager: 'node' }),
stop: async () => {},
hook: async (runtime, action) => {
installerPid = await launchStartupHook(runtime, action);
return installerPid;
},
},
});
await until(
async () => !!(await fs.stat(env.FIXTURE_PID).catch(() => false)),
);
ownPid = Number(await fs.readFile(env.FIXTURE_PID, 'utf8'));
assert.ok(ownPid > 1);
await until(() => installerPid && !live(installerPid));
assert.equal(
live(ownPid),
true,
'bot must outlive its completed installer',
);
controller.abort('SIGTERM');
assert.equal(await running, 143);
await until(() => !live(ownPid));
assert.equal(
live(foreignBot.pid),
true,
'other installation must remain running',
);
},
);
+98
View File
@@ -0,0 +1,98 @@
// Run only in a fresh disposable panel started by dist/container.js, system mode.
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawnSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { LocalApi } = require('../../dist/internal/runtime/api');
(async () => {
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
assert.equal(process.env.QL_SCHEDULER, 'system');
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
const filename = `container-cron-${randomUUID()}.js`;
const script = path.join('/ql/data/scripts', filename);
await fs.writeFile(
script,
`const fs=require('node:fs');const chain=[];let pid=process.pid;while(pid>0&&chain.length<12){try{const argv=fs.readFileSync('/proc/'+pid+'/cmdline','utf8').split('\\0').filter(Boolean);chain.push({pid,argv});const stat=fs.readFileSync('/proc/'+pid+'/stat','utf8');pid=Number(stat.slice(stat.lastIndexOf(')')+2).split(' ')[1]);}catch{break}}console.log('CRON_ANCESTRY='+JSON.stringify(chain));\n`,
);
let task;
try {
task = (
await api.call('crons', 'POST', {
name: 'Disposable real crond fixture',
command: `task ${filename}`,
schedule: '* * * * *',
})
).data;
assert.ok(task.id);
const table = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
assert.equal(table.status, 0, table.stderr);
assert.ok(
table.stdout
.split('\n')
.some((line) => !line.startsWith('#') && line.includes(filename)),
'panel must install task in real crontab',
);
console.log(
JSON.stringify({ event: 'waiting-for-crond', taskId: task.id }),
);
let chain;
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
const log = (await api.call(`crons/${task.id}/log`)).data;
const match = typeof log === 'string' && log.match(/CRON_ANCESTRY=(.*)/);
if (match) {
chain = JSON.parse(match[1]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
assert.ok(
chain,
'real minute tick must produce task output without a run API call',
);
assert.ok(
chain.some(({ argv }) => path.basename(argv[0] || '') === 'crond'),
'ancestry must include actual crond',
);
const runner = chain.find(
({ argv }) =>
path.basename(argv[0] || '') === 'node' &&
['/root/bin/task', '/opt/qinglong-cli/bin/task'].includes(argv[1]),
);
assert.ok(runner, 'scheduled task must execute the selected Node wrapper');
if (runner.argv[1] === '/root/bin/task') {
assert.match(
await fs.readFile(runner.argv[1], 'utf8'),
/\/opt\/qinglong-cli\/dist\/task.js/,
);
} else {
assert.equal(
await fs.realpath(runner.argv[1]),
'/opt/qinglong-cli/dist/task.js',
);
}
assert.ok(
chain.some(({ argv }) =>
argv.includes('/opt/qinglong-cli/dist/container.js'),
),
'scheduler must descend from the TS container entry',
);
console.log(
JSON.stringify({
realCrondMinuteTick: true,
tsRunner: true,
persistedLog: true,
tsContainerAncestor: true,
}),
);
} finally {
if (task) await api.call('crons', 'DELETE', [task.id]);
await fs.rm(script, { force: true });
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+154
View File
@@ -0,0 +1,154 @@
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 { spawnSync } = require('node:child_process');
const ts = require('typescript');
const { installCliEntrypoints } = require('../../dist/local/entrypoints');
test('2.x startup selection survives repeated linking and can return to original Shell', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entrypoints-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const home = path.join(root, 'home');
const cliRoot = path.join(root, 'CLI with spaces');
const bin = path.join(home, 'bin');
await fs.mkdir(path.join(root, 'shell'), { recursive: true });
for (const name of ['update.sh', 'task.sh'])
await fs.writeFile(path.join(root, 'shell', name), 'original');
await fs.mkdir(path.join(cliRoot, 'dist/local'), { recursive: true });
for (const [file, method] of [
['ql', 'qlMain'],
['task', 'taskMain'],
])
await fs.writeFile(
path.join(cliRoot, 'dist', `${file}.js`),
`exports.${method}=async()=>{console.log(JSON.stringify(process.argv.slice(2)));return 7;};`,
);
await fs.writeFile(
path.join(cliRoot, 'dist/local/entrypoints.js'),
`exports.installCliEntrypoints=require(${JSON.stringify(
path.resolve(__dirname, '../../dist/local/entrypoints'),
)}).installCliEntrypoints;`,
);
await fs.writeFile(
path.join(cliRoot, 'dist/local/cronEntrypoint.js'),
`module.exports=require(${JSON.stringify(
path.resolve(__dirname, '../../dist/local/cronEntrypoint'),
)});`,
);
const code = ts.transpileModule(
await fs.readFile(
path.resolve(__dirname, '../../../back/loaders/deps.ts'),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
esModuleInterop: true,
},
},
).outputText;
const errors = [];
const legacyBin = path.join(root, 'legacy-bin');
await fs.mkdir(legacyBin);
for (const name of ['ql', 'task'])
await fs.writeFile(path.join(legacyBin, name), '#!/bin/sh\nexit 99\n', {
mode: 0o755,
});
await fs.writeFile(path.join(legacyBin, 'crontab'), '#!/bin/sh\nexit 0\n', {
mode: 0o755,
});
const originalPath = [legacyBin, bin, '/usr/bin', '/bin'].join(
path.delimiter,
);
const environment = { PATH: originalPath };
const module = { exports: {} };
vm.runInNewContext(code, {
module,
exports: module.exports,
process: { env: environment },
require: (id) => {
if (id === 'os') return { homedir: () => home };
if (id === '../config/index')
return {
rootPath: root,
dataPath: path.join(root, 'data'),
crontabFile: path.join(root, 'data/config/crontab.list'),
};
if (id === './logger') return { error: (...args) => errors.push(args) };
return require(id);
},
});
await module.exports.default();
assert.equal(
await fs.readlink(path.join(bin, 'ql')),
path.join(root, 'shell/update.sh'),
);
assert.equal(environment.PATH, originalPath);
assert.equal(spawnSync('ql', [], { env: environment }).status, 99);
environment.QL_CLI_ROOT = cliRoot;
for (let i = 0; i < 2; i++) {
await module.exports.default();
assert.equal(environment.PATH.split(path.delimiter)[0], bin);
assert.equal(
environment.PATH.split(path.delimiter).filter((value) => value === bin)
.length,
1,
);
for (const name of ['ql', 'task']) {
const result = spawnSync(name, ['--literal', 'a b', '$(nothing)'], {
encoding: 'utf8',
env: { ...process.env, ...environment },
});
assert.equal(result.status, 7, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), [
'--literal',
'a b',
'$(nothing)',
]);
}
}
assert.match(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
/QingLong CLI crontab bridge/,
);
assert.deepEqual(errors, []);
const before = await fs.readFile(path.join(bin, 'ql'), 'utf8');
environment.QL_CLI_ROOT = 'relative';
await module.exports.default();
assert.equal(errors.length, 1);
assert.equal(await fs.readFile(path.join(bin, 'ql'), 'utf8'), before);
delete environment.QL_CLI_ROOT;
await module.exports.default();
await assert.rejects(fs.lstat(path.join(bin, 'crontab')), { code: 'ENOENT' });
await fs.writeFile(path.join(bin, 'crontab'), '# unrelated user command');
await module.exports.default();
assert.equal(
await fs.readFile(path.join(bin, 'crontab'), 'utf8'),
'# unrelated user command',
);
assert.equal(
await fs.readlink(path.join(bin, 'task')),
path.join(root, 'shell/task.sh'),
);
assert.equal(
await fs.readFile(path.join(root, 'shell/task.sh'), 'utf8'),
'original',
);
});
test('incomplete CLI installation leaves both existing entrypoints untouched', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-entry-incomplete-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const bin = path.join(root, 'bin');
await fs.mkdir(bin);
await fs.mkdir(path.join(root, 'dist'));
await fs.writeFile(path.join(root, 'dist/ql.js'), '');
for (const name of ['ql', 'task'])
await fs.writeFile(path.join(bin, name), 'keep');
await assert.rejects(installCliEntrypoints(bin, root));
for (const name of ['ql', 'task'])
assert.equal(await fs.readFile(path.join(bin, name), 'utf8'), 'keep');
});
+57
View File
@@ -0,0 +1,57 @@
const fs = require('node:fs/promises');
const path = require('node:path');
// Test-only reversible switch; callers must first reject initialized panels.
exports.installEvaluationEntrypoints =
async function installEvaluationEntrypoints() {
if (process.env.QL_PANEL_INTEGRATION !== '1')
throw new Error('Disposable panel required');
const changed = [];
const restore = async () => {
for (const entry of changed.reverse()) {
await fs.rm(entry.target, { force: true });
await fs.rename(entry.backup, entry.target);
}
};
try {
for (const [name, moduleName, entry] of [
['task', 'task', 'taskMain'],
['ql', 'ql', 'qlMain'],
]) {
const target = path.join(require('node:os').homedir(), 'bin', name);
const backup = `${target}.shell-evaluation-backup`;
const stat = await fs.lstat(target);
if (!stat.isSymbolicLink())
throw new Error(`Expected original symlink: ${name}`);
await fs.access(backup).then(
() => {
throw new Error('Backup already exists');
},
(error) => {
if (error.code !== 'ENOENT') throw error;
},
);
await fs.rename(target, backup);
changed.push({ target, backup });
const modulePath = path.resolve(__dirname, '../../dist', moduleName);
await fs.writeFile(
target,
`#!${process.execPath}\nrequire(${JSON.stringify(
modulePath,
)}).${entry}().then(code => {process.exitCode = code;});\n`,
{ mode: 0o755, flag: 'wx' },
);
}
if (process.env.QL_PANEL_LOADER === '1') {
const target = '/ql/static/build/loaders/deps.js';
const backup = `${target}.shell-evaluation-backup`;
await fs.rename(target, backup);
changed.push({ target, backup });
await fs.copyFile('/candidate/deps.js', target);
}
return restore;
} catch (error) {
await restore();
throw error;
}
};
+109
View File
@@ -0,0 +1,109 @@
// Run as root only inside the disposable QEMU host-startup fixture.
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { execFileSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { createContext } = require('../../dist/internal/runtime/context');
const { LocalApi } = require('../../dist/internal/runtime/api');
(async () => {
assert.equal(process.env.QL_HOST_INTEGRATION, '1');
const mode = process.argv[2];
assert.ok(['before', 'after'].includes(mode));
const stateFile = '/var/lib/ql-host-fixture.json';
const boot = (
await fs.readFile('/proc/sys/kernel/random/boot_id', 'utf8')
).trim();
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
const deadline = Date.now() + 45000;
let system;
while (Date.now() < deadline) {
try {
system = (await api.call('system')).data;
break;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
assert.ok(system, 'panel must become available without manual service start');
const osRelease = await fs.readFile('/etc/os-release', 'utf8');
const systemd = /^ID=(?:"?)(?:debian|ubuntu)(?:"?)$/m.test(osRelease);
for (const service of mode === 'after' ? ['nginx', 'crond'] : ['crond'])
execFileSync(
systemd ? 'systemctl' : 'rc-service',
systemd
? ['is-active', service === 'crond' ? 'cron' : service]
: [service, 'status'],
{ stdio: 'pipe' },
);
const nginxPid = Number(
(await fs.readFile(systemd ? '/run/nginx.pid' : '/run/nginx/nginx.pid', 'utf8')).trim(),
);
assert.ok(Number.isSafeInteger(nginxPid) && nginxPid > 1);
process.kill(nginxPid, 0);
assert.match(await fs.readFile(`/proc/${nginxPid}/comm`, 'utf8'), /^nginx/);
if (mode === 'before') {
const filename = `host-reboot-${randomUUID()}.js`;
await fs.writeFile(
`/ql/data/scripts/${filename}`,
'console.log("HOST_REBOOT_TASK_OK:"+require("node:fs").readFileSync("/proc/sys/kernel/random/boot_id","utf8").trim());',
);
const task = (
await api.call('crons', 'POST', {
name: 'Disposable host reboot task',
command: `task ${filename}`,
schedule: '* * * * *',
})
).data;
await fs.writeFile(stateFile, JSON.stringify({ boot, task, filename }), {
mode: 0o600,
flag: 'wx',
});
console.log(
JSON.stringify({
beforeBootId: boot,
taskCreated: true,
version: system.version,
}),
);
} else {
const state = JSON.parse(await fs.readFile(stateFile, 'utf8'));
assert.notEqual(boot, state.boot, 'kernel boot ID must change');
const task = (await api.call(`crons/${state.task.id}`)).data;
assert.equal(task.command, state.task.command);
let log = '';
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
log = (await api.call(`crons/${task.id}/log`)).data || '';
if (
log.includes(`HOST_REBOOT_TASK_OK:${boot}`) &&
/完成|执行结束/.test(log)
)
break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.ok(
log.includes(`HOST_REBOOT_TASK_OK:${boot}`),
'crond must run the retained task during this boot without a run API call',
);
assert.match(log, /完成|执行结束/);
console.log(
JSON.stringify({
afterBootId: boot,
automaticPanelStartup: true,
initSystem: systemd ? 'systemd' : 'openrc',
taskRetained: true,
taskExecuted: true,
nginxStarted: true,
crondStarted: true,
automaticMinuteTick: true,
version: system.version,
}),
);
await api.call('crons', 'DELETE', [task.id]);
await fs.rm(`/ql/data/scripts/${state.filename}`);
await fs.rm(stateFile);
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+74
View File
@@ -0,0 +1,74 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { spawnSync } = require('node:child_process');
const { createContext } = require('/opt/qinglong-cli/dist/internal/runtime/context');
const { LocalApi } = require('/opt/qinglong-cli/dist/internal/runtime/api');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
(async () => {
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
await fs.writeFile(
'/ql/data/scripts/image-selection.js',
'console.log("IMAGE_PARENT="+JSON.stringify(require("node:fs").readFileSync("/proc/"+process.ppid+"/cmdline","utf8").split("\\0")));',
);
const task = (
await api.call('crons', 'POST', {
name: 'image selection fixture',
command: 'task image-selection.js',
schedule: '0 0 1 1 *',
})
).data;
try {
await api.call('crons/run', 'PUT', [task.id]);
let log = '';
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
log = (await api.call(`crons/${task.id}/log`)).data;
if (log.includes('IMAGE_PARENT=')) break;
}
const parent = JSON.parse(log.match(/IMAGE_PARENT=(.*)/)[1]);
assert.match(parent[0], /(?:^|\/)node$/);
assert.equal(parent[1], '/root/bin/task');
assert.match(
await fs.readFile('/root/bin/task', 'utf8'),
/\/opt\/qinglong-cli\/dist\/task.js/,
);
const reload = spawnSync('ql', ['reload'], {
encoding: 'utf8',
timeout: 30000,
});
assert.equal(reload.status, 0, reload.stderr);
assert.equal(JSON.parse(reload.stdout).code, 200);
let healthy = false;
for (let i = 0; i < 50; i++) {
try {
healthy =
(
await (
await fetch('http://127.0.0.1:5700/api/system', {
signal: AbortSignal.timeout(1000),
})
).json()
).code === 200;
} catch {}
if (healthy) break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
assert.ok(healthy);
const help = spawnSync('ql', ['--help'], { encoding: 'utf8' });
assert.equal(help.status, 0);
assert.match(help.stdout, /ql-compat/);
console.log(
JSON.stringify({
packagedTaskScheduled: true,
packagedReload: true,
healthyAfterReload: true,
selectionRetained: true,
}),
);
} finally {
await api.call('crons', 'DELETE', [task.id]);
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+50
View File
@@ -0,0 +1,50 @@
process.channel.ref();
// Child fixture: real service startup pauses at a deterministic cancellation boundary.
const { createContext } = require('../../dist/internal/runtime/context');
const { replaceAndReload } = require('../../dist/internal/maintenance/upgrade');
const { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
const {
withCommandCancellation,
cancellableOperation,
interruptedCode,
} = require('../../dist/internal/runtime/cancellation');
const path = require('node:path');
const context = createContext({ root: process.env.TEST_ROOT }, process.env);
let starts = 0;
withCommandCancellation(async (signal) => {
try {
await cancellableOperation(signal, () =>
replaceAndReload(
context,
[
{
source: path.join(context.root, 'staged'),
target: context.paths.dir_static,
},
],
{
stop: stopPanel,
start: async (ctx) => {
const result = await startPanel(ctx);
if (++starts === 1) {
const body = await require('./service-response.cjs')('new');
process.send({ ready: true, pid: body.pid });
if (!signal.aborted)
await new Promise((resolve) =>
signal.addEventListener('abort', resolve, { once: true }),
);
}
return result;
},
},
),
);
return 0;
} catch (error) {
if (!signal.aborted) console.error(error);
return interruptedCode(signal) ?? 1;
}
}).then((code) => {
process.exitCode = code;
process.disconnect();
});
+75
View File
@@ -0,0 +1,75 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { createContext } = require('../../dist/internal/runtime/context');
const { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
test(
'data reload preserves a real mount root while replacing contents and restarting the backend',
{ timeout: 30000 },
async (t) => {
assert.equal(
process.env.QL_MOUNT_TEST,
'1',
'Run only in the documented disposable mounted-data container',
);
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-mounted-data-'));
const data = '/mounted-data';
assert.deepEqual(await fs.readdir(data), []);
const before = await fs.stat(data);
assert.notEqual(before.dev, (await fs.stat(root)).dev);
const context = createContext(
{ root, 'data-dir': data },
{ PATH: '/nonexistent' },
);
t.after(async () => {
await stopPanel(context);
await fs.rm(root, { recursive: true, force: true });
});
await fs.writeFile(path.join(data, 'version'), 'old');
await fs.writeFile(path.join(data, 'obsolete'), 'remove');
await fs.mkdir(path.join(root, '.tmp/data'), { recursive: true });
await fs.writeFile(path.join(root, '.tmp/data/version'), 'new');
await fs.writeFile(path.join(root, '.tmp/data/.hidden'), 'included');
await fs.mkdir(path.join(root, 'static/build'), { recursive: true });
await fs.writeFile(
path.join(root, 'static/build/app.js'),
`const fs=require('node:fs');if(fs.readFileSync(process.env.QL_DATA_DIR+'/version','utf8')==='broken')process.exit(7);const s=require('node:http').createServer((q,r)=>r.end(fs.readFileSync(process.env.QL_DATA_DIR+'/version')));s.listen(0,'127.0.0.1',()=>fs.writeFileSync(process.env.QL_DIR+'/port',String(s.address().port)));`,
);
await startPanel(context);
const response = async () => {
const port = await fs.readFile(path.join(root, 'port'), 'utf8');
return (
await fetch('http://127.0.0.1:' + port, {
signal: AbortSignal.timeout(3000),
})
).text();
};
assert.equal(await response(), 'old');
const result = await reloadPanel(context, 'data');
assert.deepEqual(result.retainedBackups, []);
assert.equal(await response(), 'new');
assert.equal((await fs.stat(data)).ino, before.ino);
assert.equal((await fs.stat(data)).dev, before.dev);
await assert.rejects(fs.access(path.join(data, 'obsolete')));
assert.equal(
await fs.readFile(path.join(data, '.hidden'), 'utf8'),
'included',
);
assert.ok(
!(await fs.readdir(data)).some((name) => name.includes('ql-backup')),
);
await fs.writeFile(path.join(root, '.tmp/data/version'), 'broken');
await fs.writeFile(path.join(root, '.tmp/data/new-only'), 'must roll back');
await assert.rejects(reloadPanel(context, 'data'), /startup/);
assert.equal(await response(), 'new');
assert.equal((await fs.stat(data)).ino, before.ino);
await assert.rejects(fs.access(path.join(data, 'new-only')));
assert.ok(
!(await fs.readdir(data)).some((name) => name.includes('ql-backup')),
);
},
);
+61
View File
@@ -0,0 +1,61 @@
// Fresh disposable official panel only; never run against an initialized panel.
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const fs = require('node:fs/promises');
(async () => {
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
const url = 'http://127.0.0.1:5700';
const api = async (route, method = 'GET', body) => {
const response = await fetch(url + '/api/' + route, { method,
headers: { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
const result = await response.json(); assert.equal(result.code, 200); return result.data;
};
const info = await api('system'); assert.equal(info.isInitialized, false, 'Refuse initialized panels');
const credentials = { username: 'fixture-' + randomUUID(), password: randomUUID() };
await api('user/init', 'PUT', credentials);
const owner = (await api('user/login', 'POST', credentials)).token;
const config = '/tmp/ql-openapi-auth.json';
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
const cli = (args, ownerMode = false, extra = {}) => {
const result = spawnSync(process.execPath, [entry, ...args, '--json'], {
encoding: 'utf8', timeout: 30000,
env: { ...process.env, QL_CLI_CONFIG: config, QL_URL: ownerMode ? url : '', QL_ACCESS_TOKEN: ownerMode ? owner : '', ...extra },
});
// Report only the command name, never request bodies or application credentials.
assert.equal(result.status, 0, args.slice(0, 2).join(' ') + ': ' + result.stderr);
return JSON.parse(result.stdout).data;
};
const app = cli(['app', 'create', '--name', 'cli-fixture', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps', '--show-secrets'], true);
assert.ok(app.client_id && app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: app.client_secret });
cli(['auth', 'status', '--scope', 'apps']);
cli(['app', 'update', String(app.id), '--name', 'cli-updated', '--scopes', 'crons,subscriptions,envs,configs,scripts,logs,dependencies,system,dashboard,apps']);
assert.ok(cli(['app', 'list']).every(item => !('client_secret' in item) && !('tokens' in item)));
const task = cli(['task', 'create', '--name', 'cli-task', '--command', 'echo OPENAPI_FIXTURE', '--schedule', '0 0 * * *']);
cli(['task', 'update', String(task.id), '--name', 'cli-task-updated', '--command', 'echo OPENAPI_UPDATED', '--schedule', '0 1 * * *']);
cli(['task', 'disable', String(task.id)]); cli(['task', 'enable', String(task.id)]);
assert.equal(cli(['task', 'get', String(task.id)]).name, 'cli-task-updated');
const sub = cli(['subscription', 'create', '--type', 'file', '--url', url + '/fixture.js', '--alias', 'cli-sub', '--schedule-type', 'crontab', '--schedule', '0 0 * * *']);
cli(['subscription', 'update', String(sub.id), '--data', JSON.stringify({ type: 'file', url: url + '/fixture.js', alias: 'cli-sub', name: 'cli-sub-updated', schedule_type: 'crontab', schedule: '0 1 * * *' })]);
cli(['subscription', 'disable', String(sub.id)]); cli(['subscription', 'enable', String(sub.id)]);
assert.equal(cli(['subscription', 'get', String(sub.id)]).name, 'cli-sub-updated');
const env = cli(['env', 'create', '--data', '[{"name":"CLI_FIXTURE","value":"one"}]'])[0];
cli(['env', 'update', String(env.id), '--data', '{"name":"CLI_FIXTURE","value":"two"}']);
assert.equal(cli(['env', 'get', String(env.id)]).value, 'two');
cli(['env', 'delete', String(env.id)]);
cli(['script', 'create', '--data', '{"filename":"cli-openapi.js","content":"console.log(1)","path":""}']);
assert.equal(cli(['script', 'get', '--query', '{"file":"cli-openapi.js"}']), 'console.log(1)');
cli(['config', 'save', '--data', '{"name":"cli-openapi.sh","content":"# fixture"}']);
assert.equal(cli(['config', 'get', '--query', '{"path":"cli-openapi.sh"}']), '# fixture');
cli(['log', 'list']); cli(['dependency', 'list']);
cli(['task', 'delete', String(task.id)]); cli(['subscription', 'delete', String(sub.id)]);
const reset = cli(['app', 'reset-secret', String(app.id), '--show-secrets'], true);
assert.ok(reset.client_secret !== app.client_secret);
cli(['login', '--url', url], false, { QL_CLIENT_ID: app.client_id, QL_CLIENT_SECRET: reset.client_secret });
cli(['auth', 'status']); cli(['app', 'delete', String(app.id)], true);
await fs.rm(config, { force: true });
console.log(JSON.stringify({ panelVersion: info.version, taskCrud: true, subscriptionCrud: true, appCrudAndSecretRotation: true, envCrud: true, scriptCreateRead: true, configSaveRead: true, logAndDependencyRead: true }));
})().catch(error => { console.error(error.message); process.exitCode = 1; });
+56
View File
@@ -0,0 +1,56 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { spawnSync } = require('node:child_process');
exports.verifyAccountMaintenance = async function verifyAccountMaintenance(
api,
) {
const credentials = {
username: `renamed-${randomUUID()}`,
password: `--${randomUUID()}`,
};
const local = (action, value) => {
const result = spawnSync(
path.join(os.homedir(), 'bin/ql'),
[action, ...(value ? ['--', value] : [])],
{
env: { ...process.env, QL_DIR: '/ql' },
encoding: 'utf8',
timeout: 15000,
},
);
assert.equal(result.status, 0, `${action} failed: ${result.stderr}`);
assert.equal(JSON.parse(result.stdout).data.completed, true);
};
const login = async () => {
const response = await fetch('http://127.0.0.1:5700/api/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
signal: AbortSignal.timeout(5000),
});
return response.json();
};
local('resetname', credentials.username);
local('resetpwd', credentials.password);
assert.equal((await login()).code, 200, 'New credentials were not saved');
await api('system/auth/reset', 'PUT', { retries: 4 });
assert.notEqual(
(await login()).code,
200,
'Fixture failed to activate login limit',
);
local('resetlet');
assert.equal((await login()).code, 200, 'Login limit was not cleared');
await api('system/auth/reset', 'PUT', { twoFactorActivated: true });
assert.notEqual(
(await login()).code,
200,
'Fixture failed to activate second factor',
);
local('resettfa');
assert.equal((await login()).code, 200, 'Second factor was not cleared');
return true;
};
+39
View File
@@ -0,0 +1,39 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { randomUUID } = require('node:crypto');
const { execFileSync } = require('node:child_process');
(async () => {
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
const file = `inventory-${randomUUID()}.js`;
const target = path.join('/ql/data/scripts', file);
await fs.writeFile(
target,
'throw Error("inventory must not execute");\nconst $ = new Env("Packaged inventory fixture");\n',
{ flag: 'wx' },
);
try {
const result = JSON.parse(
execFileSync(path.join(os.homedir(), 'bin/task'), ['--json'], {
env: { ...process.env, QL_DIR: '/ql' },
encoding: 'utf8',
timeout: 10000,
}),
);
assert.equal(result.code, 200);
assert.deepEqual(
result.data.scripts.find((row) => row.file === file),
{ file, name: 'Packaged inventory fixture' },
);
console.log(
JSON.stringify({ packagedTaskInventory: true, scriptExecuted: false }),
);
} finally {
await fs.rm(target);
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
exports.verifyOperator = async function verifyOperator(api) {
const ql = path.join(os.homedir(), 'bin/ql');
const invoke = (args) => {
const result = spawnSync(ql, args, {
env: { ...process.env, QL_DIR: '/ql' },
encoding: 'utf8',
timeout: 45000,
});
assert.equal(result.status, 0, `${args[0]} failed: ${result.stderr}`);
return JSON.parse(result.stdout).data;
};
const dir = '/ql/data/log/cli-retention';
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(`${dir}/2000-01-01-unused.log`, 'unused');
await fs.writeFile(`${dir}/2000-01-01-kept.log`, 'referenced');
const task = (
await api('crons', 'POST', {
name: 'CLI retention reference',
command: 'echo retention',
schedule: '0 0 1 1 *',
})
).data;
try {
await api('crons/status', 'PUT', {
ids: [task.id],
status: '0',
log_path: 'cli-retention/2000-01-01-kept.log',
});
const result = invoke(['rmlog', '7']);
assert.ok(result.removed.includes('cli-retention/2000-01-01-unused.log'));
assert.ok(result.retained.includes('cli-retention/2000-01-01-kept.log'));
await assert.rejects(fs.access(`${dir}/2000-01-01-unused.log`));
assert.equal(
await fs.readFile(`${dir}/2000-01-01-kept.log`, 'utf8'),
'referenced',
);
} finally {
await api('crons', 'DELETE', [task.id]);
}
const service = invoke(['reload']);
assert.ok(['pm2', 'node'].includes(service.manager));
let ready = false;
for (let i = 0; i < 60; i++) {
try {
const response = await fetch('http://127.0.0.1:5700/api/system', {
signal: AbortSignal.timeout(500),
});
if ((await response.json()).code === 200) {
ready = true;
break;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(ready, true, 'Panel failed to recover after reload');
const retained = !(await fs.lstat(ql)).isSymbolicLink();
assert.equal(
retained,
true,
'Panel startup overwrote the TypeScript ql entrypoint',
);
return true;
};
+141
View File
@@ -0,0 +1,141 @@
// Explicit opt-in: execute only in the disposable packaged evaluation panel.
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { execFileSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { LocalApi } = require('../../dist/internal/runtime/api');
(async () => {
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
const api = new LocalApi(createContext({ root: '/ql' }, process.env));
const marker = randomUUID();
const name = `ql-preload-${marker}`;
const global = execFileSync('pnpm', ['root', '-g'], {
encoding: 'utf8',
}).trim();
assert.ok(path.isAbsolute(global));
const packagePath = path.join(global, name);
const beforePath = '/ql/data/config/task_before.sh';
const before = await fs.readFile(beforePath);
const ids = [],
scripts = [];
await fs.mkdir(packagePath, { recursive: true });
try {
await fs.writeFile(
path.join(packagePath, 'package.json'),
JSON.stringify({
name,
type: 'module',
exports: { '.': './index.mjs', './feature': './feature.mjs' },
}),
);
await fs.writeFile(
path.join(packagePath, 'index.mjs'),
`export default ${JSON.stringify(marker)};`,
);
await fs.writeFile(
path.join(packagePath, 'feature.mjs'),
`export default ${JSON.stringify(marker)};`,
);
await fs.appendFile(beforePath, `\nexport QL_PRELOAD_FIXTURE=${marker}\n`);
for (const extension of ['js', 'mjs', 'py']) {
const filename = `${name}.${extension}`;
const file = path.join('/ql/data/scripts', filename);
scripts.push(file);
const source =
extension === 'py'
? 'import os,json,builtins\nprint("PANEL_PRELOAD:"+json.dumps([os.getenv("QL_PRELOAD_FIXTURE"),hasattr(builtins,"QLAPI")]))\n'
: extension === 'mjs'
? `import value from '${name}/feature'; console.log('PANEL_PRELOAD:'+JSON.stringify([process.env.QL_PRELOAD_FIXTURE,!!globalThis.QLAPI,value]));`
: "console.log('PANEL_PRELOAD:'+JSON.stringify([process.env.QL_PRELOAD_FIXTURE,!!globalThis.QLAPI]));";
await fs.writeFile(file, source);
const task = (
await api.call('crons', 'POST', {
name: `Packaged preload ${extension}`,
command: `task ${filename}`,
schedule: '0 0 1 1 *',
})
).data;
ids.push(task.id);
if (
extension === 'js' &&
process.env.QL_PANEL_RELOAD_INTEGRATION === '1'
) {
const reload = JSON.parse(
execFileSync(
process.execPath,
[
path.resolve(__dirname, '../../dist/admin.js'),
'reload',
'--root',
'/ql',
'--json',
],
{ encoding: 'utf8', timeout: 60000 },
),
);
assert.equal(reload.code, 200);
const deadline = Date.now() + 30000;
let saved;
while (Date.now() < deadline) {
try {
saved = (await api.call(`crons/${task.id}`)).data;
break;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(
saved?.command,
task.command,
'task must survive services reload',
);
console.log(
JSON.stringify({ servicesReloaded: true, taskRetained: true }),
);
}
await api.call('crons/run', 'PUT', [task.id]);
let log = '';
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
log = (await api.call(`crons/${task.id}/log`)).data || '';
if (log.includes('PANEL_PRELOAD:') && /完成|执行结束/.test(log)) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
const found = log.match(/PANEL_PRELOAD:(.*)/);
assert.ok(found, `${extension}: ${log}`);
assert.deepEqual(
JSON.parse(found[1]),
extension === 'mjs' ? [marker, true, marker] : [marker, true],
);
assert.doesNotMatch(log, /run task before error|run builtin code error/);
console.log(
JSON.stringify({
language: extension,
panelScheduled: true,
shellHook: true,
QLAPI: true,
...(extension === 'mjs' ? { globalExportedSubpath: true } : {}),
}),
);
}
} finally {
const cleaned = await Promise.allSettled([
...(ids.length ? [api.call('crons', 'DELETE', ids)] : []),
fs.writeFile(beforePath, before),
...scripts.map((file) => fs.rm(file, { force: true })),
fs.rm(packagePath, { recursive: true, force: true }),
]);
const failed = cleaned.filter((result) => result.status === 'rejected');
if (failed.length)
throw new AggregateError(
failed.map((result) => result.reason),
'Disposable preload cleanup failed',
);
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+132
View File
@@ -0,0 +1,132 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { pathToFileURL } = require('node:url');
exports.verifyRepository = async function verifyRepository(api, cli) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-repository-gate-'));
const repository = path.join(root, 'fixture');
await fs.mkdir(repository);
const git = (...args) =>
execFileSync('git', ['-C', repository, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
git('init', '-b', 'main');
git('config', 'user.name', 'CLI fixture');
git('config', 'user.email', 'fixture@example.invalid');
await fs.writeFile(
path.join(repository, 'job-wrong.js'),
'console.log("WRONG_BRANCH")',
);
git('add', '.');
git('commit', '-m', 'main');
git('checkout', '-b', 'selected');
await fs.rm(path.join(repository, 'job-wrong.js'));
await fs.writeFile(
path.join(repository, 'shared.js'),
'module.exports="DEPENDENCY_OK";',
);
await fs.writeFile(
path.join(repository, 'job-keep.js'),
'// cron: 0 0 1 1 *\nconsole.log(require("./shared"));',
);
await fs.writeFile(
path.join(repository, 'job-old.js'),
'// cron: 0 0 1 1 *\nconsole.log("old");',
);
await fs.writeFile(
path.join(repository, 'job-excluded.js'),
'throw Error("excluded");',
);
git('add', '.');
git('commit', '-m', 'selected');
let subscription;
const tasks = () =>
cli(['task', 'list']).data.data.filter(
(row) => row.sub_id === subscription.id,
);
try {
subscription = (
await api('subscriptions', 'POST', {
name: 'CLI repository integration',
alias: 'cli-repository-integration',
type: 'public-repo',
url: pathToFileURL(repository).href,
branch: 'selected',
whitelist: 'job-',
blacklist: 'excluded',
dependences: 'shared',
extensions: 'js',
schedule_type: 'crontab',
schedule: '0 0 1 1 *',
autoAddCron: true,
autoDelCron: true,
})
).data;
const run = async (added, removed) => {
cli(['subscription', 'run', String(subscription.id)]);
let log = '';
for (let i = 0; i < 80; i++) {
await new Promise((resolve) => setTimeout(resolve, 250));
log = cli(['subscription', 'logs', String(subscription.id)]).data;
if (log.includes(`"added":${added},"removed":${removed}`)) return;
}
assert.fail(`Repository sync did not finish: ${log}`);
};
await run(2, 0);
let rows = tasks();
assert.equal(rows.length, 2);
assert.ok(rows.some((row) => row.command.endsWith('/job-old.js')));
const keep = rows.find((row) => row.command.endsWith('/job-keep.js'));
assert.ok(keep);
const directory = path.dirname(`/ql/data/scripts/${keep.command.slice(5)}`);
assert.match(
await fs.readFile(path.join(directory, 'shared.js'), 'utf8'),
/DEPENDENCY_OK/,
);
await assert.rejects(fs.access(path.join(directory, 'job-wrong.js')));
await assert.rejects(fs.access(path.join(directory, 'job-excluded.js')));
cli(['task', 'run', String(keep.id)]);
let taskLog = '';
for (let i = 0; i < 60; i++) {
await new Promise((resolve) => setTimeout(resolve, 250));
taskLog = cli(['task', 'logs', String(keep.id)]).data;
if (taskLog.includes('DEPENDENCY_OK') && /完成|执行结束/.test(taskLog))
break;
}
assert.match(taskLog, /DEPENDENCY_OK/);
await fs.rm(path.join(repository, 'job-old.js'));
await fs.writeFile(
path.join(repository, 'job-new.js'),
'// cron: 0 0 1 1 *\nconsole.log("new");',
);
git('add', '-A');
git('commit', '-m', 'replace task');
await run(1, 1);
rows = tasks();
assert.equal(rows.length, 2);
assert.equal(
rows.find((row) => row.command.endsWith('/job-keep.js')).id,
keep.id,
);
assert.ok(rows.some((row) => row.command.endsWith('/job-new.js')));
assert.ok(!rows.some((row) => row.command.endsWith('/job-old.js')));
await assert.rejects(fs.access(path.join(directory, 'job-old.js')));
return true;
} finally {
if (subscription) {
const rows = tasks();
if (rows.length)
await api(
'crons',
'DELETE',
rows.map((row) => row.id),
);
await api('subscriptions', 'DELETE', [subscription.id]);
}
await fs.rm(root, { recursive: true, force: true });
}
};
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const http = require('node:http');
exports.verifySubscription = async function verifySubscription(api, cli) {
const marker = 'CLI_SUBSCRIPTION_PANEL_MARKER';
const server = http.createServer((req, res) =>
res.end(`// cron: 0 0 1 1 *\nconsole.log('${marker}');\n`),
);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
let subscription;
let generated = [];
try {
subscription = (
await api('subscriptions', 'POST', {
name: 'CLI raw integration',
alias: 'cli-raw-integration',
type: 'file',
url: `http://127.0.0.1:${server.address().port}/fixture.js`,
schedule_type: 'crontab',
schedule: '0 0 1 1 *',
autoAddCron: true,
autoDelCron: false,
})
).data;
const id = String(subscription.id);
assert.equal(cli(['subscription', 'run', id]).data.accepted, true);
let log = '';
for (let attempt = 0; attempt < 80; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
log = cli(['subscription', 'logs', id]).data;
if (log.includes('"added":1')) break;
}
assert.match(log, /"added":1/, log);
assert.doesNotMatch(log, /reporting failed/);
generated = cli(['task', 'list']).data.data.filter(
(row) => row.sub_id === subscription.id,
);
assert.equal(generated.length, 1);
const task = generated[0];
assert.match(task.command, /^task raw_/);
assert.match(
await fs.readFile(`/ql/data/scripts/${task.command.slice(5)}`, 'utf8'),
new RegExp(marker),
);
cli(['task', 'run', String(task.id)]);
let taskLog = '';
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
taskLog = cli(['task', 'logs', String(task.id)]).data;
if (taskLog.includes(marker) && /完成|执行结束/.test(taskLog)) break;
}
assert.match(taskLog, new RegExp(marker));
assert.match(taskLog, /完成|执行结束/);
return true;
} finally {
if (generated.length)
await api(
'crons',
'DELETE',
generated.map((row) => row.id),
);
if (subscription) await api('subscriptions', 'DELETE', [subscription.id]);
await new Promise((resolve) => {
server.close(resolve);
server.closeAllConnections();
});
}
};
+116
View File
@@ -0,0 +1,116 @@
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { randomUUID } = require('node:crypto');
const { createContext } = require('../../dist/internal/runtime/context');
const { installPanelDependencies } = require('../../dist/internal/maintenance/operator');
const { reloadPanel } = require('../../dist/internal/maintenance/upgrade');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
(async () => {
let token;
const api = async (endpoint, method = 'GET', body) => {
const response = await fetch(`http://127.0.0.1:5700/api/${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
const result = await response.json();
assert.equal(result.code, 200, `${method} ${endpoint}: ${result.code}`);
return result;
};
const before = (await api('system')).data;
assert.equal(before.version, '2.19.0');
assert.equal(before.isInitialized, false);
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
const verifySelection = async () => {
for (const [name, module] of [
['task', 'runner'],
['ql', 'compat'],
]) {
const entry = `/root/bin/${name}`;
assert.equal((await fs.lstat(entry)).isSymbolicLink(), false);
assert.ok(
(await fs.readFile(entry, 'utf8')).includes(
`/opt/qinglong-cli/dist/${module}.js`,
),
);
}
};
await verifySelection();
const credentials = {
username: `upgrade-${randomUUID()}`,
password: randomUUID(),
};
await api('user/init', 'PUT', credentials);
token = (await api('user/login', 'POST', credentials)).data.token;
await fs.writeFile(
'/ql/data/scripts/upgrade-fixture.js',
'console.log("UPGRADE_PRESERVED_TASK");',
);
const task = (
await api('crons', 'POST', {
name: 'upgrade retained task',
command: 'task upgrade-fixture.js',
schedule: '0 0 1 1 *',
})
).data;
await fs.appendFile(
'/ql/data/config/config.sh',
'\n# UPGRADE_PRESERVED_CONFIG\n',
);
const config = await fs.readFile('/ql/data/config/config.sh');
const dotenv = await fs.readFile('/ql/.env');
const context = createContext({ root: '/ql' }, process.env);
await installPanelDependencies(context, '/stage/source');
const replacement = await reloadPanel(context, 'system', {
source: '/stage/source',
static: '/stage/static',
});
assert.deepEqual(replacement.retainedBackups, []);
let after;
for (let i = 0; i < 60; i++) {
try {
after = (await api('system')).data;
if (after.version === '2.20.1') break;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 250));
}
assert.equal(after?.version, '2.20.1');
await verifySelection();
token = (await api('user/login', 'POST', credentials)).data.token;
const persisted = (await api(`crons/${task.id}`)).data;
assert.equal(persisted.name, task.name);
assert.equal(persisted.command, task.command);
assert.deepEqual(await fs.readFile('/ql/data/config/config.sh'), config);
assert.deepEqual(await fs.readFile('/ql/.env'), dotenv);
await api('crons/run', 'PUT', [task.id]);
let log = '';
for (let i = 0; i < 60; i++) {
await new Promise((resolve) => setTimeout(resolve, 250));
log = (await api(`crons/${task.id}/log`)).data;
if (log.includes('UPGRADE_PRESERVED_TASK') && /完成|执行结束/.test(log))
break;
}
assert.match(log, /UPGRADE_PRESERVED_TASK/);
assert.match(log, /完成|执行结束/);
console.log(
JSON.stringify({
from: before.version,
to: after.version,
tsCommandSelectionPreserved: true,
accountPreserved: true,
taskPreserved: true,
scriptExecuted: true,
configurationPreserved: true,
dotenvPreserved: true,
backupsCleaned: true,
}),
);
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
+222
View File
@@ -0,0 +1,222 @@
// Run only inside a fresh, disposable official 2.x panel container.
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { spawnSync } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { executeTask } = require('../../dist/internal/execution/taskRunner');
let restoreEntrypoints;
(async () => {
assert.equal(
process.env.QL_PANEL_INTEGRATION,
'1',
'Disposable-panel opt-in required',
);
const base = 'http://127.0.0.1:5700';
let token;
async function api(endpoint, method = 'GET', body) {
const response = await fetch(`${base}/api/${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
const result = await response.json();
assert.equal(
result.code,
200,
`${method} ${endpoint} failed: ${response.status}/${result.code}`,
);
return result;
}
const system = await api('system');
assert.equal(
system.data.isInitialized,
false,
'Refuse to alter an initialized panel',
);
const switched = process.env.QL_PANEL_ENTRYPOINTS === '1';
if (switched && process.env.QL_PANEL_PACKAGED === '1') {
assert.equal(process.env.QL_CLI_ROOT, '/opt/qinglong-cli');
for (const [name, module] of [['task', 'task'], ['ql', 'ql']]) {
const target = require('node:path').join(require('node:os').homedir(), 'bin', name);
assert.equal((await fs.lstat(target)).isSymbolicLink(), false);
assert.ok((await fs.readFile(target, 'utf8')).includes(Buffer.from(`/opt/qinglong-cli/dist/${module}.js`).toString('base64')));
}
} else if (switched)
restoreEntrypoints =
await require('./evaluation-entrypoints.cjs').installEvaluationEntrypoints();
const credentials = {
username: `fixture-${randomUUID()}`,
password: randomUUID(),
};
await api('user/init', 'PUT', credentials);
token = (await api('user/login', 'POST', credentials)).data.token;
assert.equal(typeof token, 'string');
const app = (
await api('apps', 'POST', {
name: 'CLI integration',
scopes: ['crons', 'subscriptions'],
})
).data;
const env = {
...process.env,
QL_CLI_CONFIG: '/tmp/ql-integration-auth.json',
QL_CLIENT_ID: app.client_id,
QL_CLIENT_SECRET: app.client_secret,
};
const cli = (args) => {
const result = spawnSync(
process.execPath,
[path.resolve(__dirname, '../../dist/npm/ql.js'), ...args, '--json'],
{
env,
encoding: 'utf8',
timeout: 20000,
},
);
assert.equal(
result.status,
0,
`CLI ${args.slice(0, 2).join(' ')} failed: ${result.stderr}`,
);
return JSON.parse(result.stdout);
};
assert.equal(cli(['login', '--url', base]).data.authenticated, true);
assert.equal(cli(['auth', 'status']).data.authenticated, true);
assert.equal(
cli(['auth', 'status', '--scope', 'subscriptions']).data.authenticated,
true,
);
assert.ok(Array.isArray(cli(['subscription', 'list']).data));
const filename = 'cli-integration.js';
await fs.writeFile(
`/ql/data/scripts/${filename}`,
'console.log("CLI_REAL_PANEL_MARKER");',
);
const task = (
await api('crons', 'POST', {
name: 'CLI integration',
command: `task ${filename}`,
schedule: '0 0 1 1 *',
})
).data;
const id = String(task.id);
assert.equal(cli(['task', 'get', id]).data.id, task.id);
assert.equal(cli(['task', 'run', id]).data.accepted, true);
let observed = false;
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
const logs = cli(['task', 'logs', id]);
if (
logs.data.includes('CLI_REAL_PANEL_MARKER') &&
/完成|执行结束/.test(logs.data)
) {
observed = true;
break;
}
}
assert.equal(
observed,
true,
'Legacy panel task did not finish with expected logs',
);
if (switched) {
await fs.writeFile(
'/ql/data/config/extra.sh',
'printf "CLI_COMPAT_PANEL_MARKER\\n"\n',
);
const maintenance = (
await api('crons', 'POST', {
name: 'CLI compat maintenance',
command: 'ql extra',
schedule: '0 0 1 1 *',
})
).data;
const maintenanceId = String(maintenance.id);
cli(['task', 'run', maintenanceId]);
let done = false;
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 250));
const logs = cli(['task', 'logs', maintenanceId]).data;
if (
logs.includes('CLI_COMPAT_PANEL_MARKER') &&
logs.includes('执行结束')
) {
done = true;
break;
}
}
assert.equal(
done,
true,
'Switched ql did not complete through panel scheduling',
);
const saved = cli(['task', 'get', maintenanceId]).data;
assert.match(saved.log_path, /^ql\//);
assert.ok(saved.last_execution_time > 0);
await api('crons', 'DELETE', [maintenance.id]);
}
const subscriptionExecution = switched
? await require('./panel-subscription.cjs').verifySubscription(api, cli)
: false;
const repositoryExecution = switched
? await require('./panel-repository.cjs').verifyRepository(api, cli)
: false;
// Exercise the migrated runner against the real local-token generator/status API.
await fs.writeFile(
`/ql/data/scripts/${filename}`,
'console.log("CLI_NATIVE_PANEL_MARKER");process.exit(7);',
);
const context = createContext(
{ root: '/ql' },
{ ...process.env, ID: id, no_tee: 'true', QlPort: '5700' },
);
const result = await executeTask(context, {
argv: [filename],
mode: 'now',
output: () => {},
});
assert.equal(result.exitCode, 7);
const persisted = cli(['task', 'get', id]).data;
assert.equal(persisted.log_path, result.logPath);
assert.match(cli(['task', 'logs', id]).data, /CLI_NATIVE_PANEL_MARKER/);
assert.ok(persisted.last_execution_time > 0);
await api('crons', 'DELETE', [task.id]);
const operatorMaintenance = switched
? await require('./panel-operator.cjs').verifyOperator(api)
: false;
const accountMaintenance = switched
? await require('./panel-account.cjs').verifyAccountMaintenance(api)
: false;
cli(['auth', 'logout']);
await fs.rm(`/ql/data/scripts/${filename}`);
console.log(
JSON.stringify({
panelVersion: system.data.version,
applicationAuth: true,
subscriptionRead: true,
subscriptionExecution,
repositoryExecution,
accountMaintenance,
operatorMaintenance,
remoteRunAndLogs: true,
switchedEntrypoints: switched,
localRunnerExitCode: result.exitCode,
persistedLogPath: true,
}),
);
})()
.catch(() => {
// Assertions can embed environment values or subprocess output in their message.
console.error('Disposable panel integration failed; inspect the failing scenario locally.');
process.exitCode = 1;
})
.finally(async () => {
if (restoreEntrypoints) await restoreEntrypoints();
});
+54
View File
@@ -0,0 +1,54 @@
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 { spawn } = require('node:child_process');
// Separate acceptance gate until the reused language preloaders handle these
// paths. This runs the same real-language assertions as the normal preload suite.
test(
'language preloaders preserve hooks when the installation path contains spaces',
{
skip: process.env.QL_PRELOAD_PATH_INTEGRATION !== '1',
timeout: 120000,
},
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql preload paths '));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const env = { ...process.env, TMPDIR: root, TMP: root, TEMP: root };
delete env.NODE_TEST_CONTEXT;
const child = spawn(
process.execPath,
[
'--test',
'--test-reporter=tap',
path.resolve(__dirname, '../preload.test.cjs'),
],
{
env,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
t.after(() => {
if (child.exitCode === null) child.kill('SIGKILL');
});
let output = '';
child.stdout.on('data', (chunk) => {
output += chunk;
});
child.stderr.on('data', (chunk) => {
output += chunk;
});
const status = await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code, signal) => resolve({ code, signal }));
});
assert.deepEqual(status, { code: 0, signal: null }, output);
assert.match(
output,
/# tests 6\b/,
'the child must actually run all five languages',
);
},
);
+98
View File
@@ -0,0 +1,98 @@
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 { createContext } = require('../../dist/internal/runtime/context');
const operator = require('../../dist/internal/maintenance/operator');
const { stageUpgrade } = require('../../dist/internal/maintenance/upgrade');
const { cancellableOperation } = require('../../dist/internal/runtime/cancellation');
test(
'published master archives pass actual HTTPS download and staging validation',
{
skip: process.env.QL_ARCHIVE_INTEGRATION !== '1',
timeout: 180000,
},
async (t) => {
const mirror = process.env.QL_ARCHIVE_MIRROR || 'github';
assert.ok(['github', 'gitee'].includes(mirror));
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-published-archive-'),
);
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.writeFile(
path.join(root, 'package.json'),
'{"name":"isolated-archive-gate"}',
);
const context = createContext(
{ root },
{ PATH: process.env.PATH, HOME: root, QL_BRANCH: 'master' },
);
const installations = [];
// This gate must not execute code from the downloaded source. Real dependency
// installation and retained-data upgrade are separate existing Linux gates.
t.mock.method(
operator,
'installPanelDependencies',
async (ctx, directory) => {
assert.equal(ctx.root, root);
assert.equal(
path.dirname(path.dirname(directory)),
context.paths.dir_tmp,
);
installations.push(directory);
},
);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort('SIGTERM'), 150000);
t.after(() => clearTimeout(timer));
const staged = await cancellableOperation(controller.signal, () =>
stageUpgrade(context, mirror),
);
assert.deepEqual(installations, [staged.source]);
const release = await fs.readFile(
path.join(staged.source, 'version.yaml'),
'utf8',
);
assert.match(
release,
/^version:\s*2\./m,
'Published branch must still be 2.x',
);
await fs.access(path.join(staged.static, 'build/app.js'));
const workspace = path.dirname(staged.source);
const pointer = JSON.parse(
await fs.readFile(
path.join(context.paths.dir_tmp, 'upgrade-ready-master.json'),
'utf8',
),
);
assert.equal(pointer.directory, path.basename(workspace));
assert.deepEqual(
JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')),
staged,
);
assert.equal(
await fs.readFile(path.join(root, 'package.json'), 'utf8'),
'{"name":"isolated-archive-gate"}',
);
const archives = {};
for (const name of ['qinglong', 'qinglong-static']) {
const content = await fs.readFile(path.join(workspace, `${name}.zip`));
archives[name] = {
bytes: content.length,
sha256: createHash('sha256').update(content).digest('hex'),
};
}
t.diagnostic(
JSON.stringify({
mirror,
release: release.match(/^version:\s*(\S+)/m)?.[1],
archives,
downloadedCodeExecuted: false,
}),
);
},
);
+14
View File
@@ -0,0 +1,14 @@
module.exports = async function serviceResponse(version) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const response = await fetch('http://127.0.0.1:5811', {
signal: AbortSignal.timeout(500),
});
const body = await response.json();
if (response.ok && body.version === version) return body;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`HTTP service did not reach version ${version}`);
};
+101
View File
@@ -0,0 +1,101 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { createContext } = require('../../dist/internal/runtime/context');
const { bootstrapPanel } = require('../../dist/internal/maintenance/bootstrap');
const { stopPanel } = require('../../dist/internal/maintenance/operator');
const { runProcess } = require('../../dist/internal/runtime/process');
test(
'real PM2 and nginx bootstrap reload serves requests and replaces configuration',
{ timeout: 45000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-services-'));
const context = createContext(
{ root },
{
PATH: process.env.PATH,
PM2_HOME: path.join(root, 'pm2'),
HOME: root,
},
);
const nginxConfig = path.join(root, 'nginx.conf');
const options = {
cwd: root,
env: context.env,
output: () => {},
timeoutMs: 10000,
};
t.after(async () => {
await runProcess('nginx', ['-c', nginxConfig, '-s', 'quit'], options);
await runProcess('pm2', ['kill'], options);
await fs.rm(root, { recursive: true, force: true });
});
await fs.mkdir(path.join(root, 'sample'));
for (const name of [
'config.sample.sh',
'task.sample.sh',
'extra.sample.sh',
'notify.py',
'notify.js',
'ql_sample.js',
'ql_sample.py',
])
await fs.writeFile(path.join(root, 'sample', name), '');
await fs.writeFile(path.join(root, '.env.example'), 'FIXTURE=1\n');
await fs.mkdir(path.join(root, 'static/build'), { recursive: true });
await fs.writeFile(
path.join(root, 'static/build/app.js'),
'require("http").createServer((q,s)=>s.end(JSON.stringify({pid:process.pid}))).listen(5801,"127.0.0.1")',
);
await fs.writeFile(
path.join(root, 'ecosystem.config.js'),
'module.exports={apps:[{name:"fixture-panel",script:"static/build/app.js",instances:1,exec_mode:"fork"}]}',
);
const writeNginx = (generation) =>
fs.writeFile(
nginxConfig,
`pid ${root}/nginx.pid;\nerror_log ${root}/nginx-error.log;\nevents {}\nhttp { access_log off; server { listen 127.0.0.1:5802; location / { add_header X-Fixture ${generation}; proxy_pass http://127.0.0.1:5801; } } }\n`,
);
await writeNginx('one');
const system = {
nginxConfig,
nginxIncludes: path.join(root, 'nginx-includes'),
nginxRun: path.join(root, 'nginx-run'),
background: async () => {
throw new Error('reload must not dispatch hooks');
},
};
async function response(generation) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const result = await fetch('http://127.0.0.1:5802', {
signal: AbortSignal.timeout(500),
});
const body = await result.json();
if (result.ok && result.headers.get('x-fixture') === generation)
return body;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`nginx did not serve generation ${generation}`);
}
const first = await bootstrapPanel(context, true, system);
assert.equal(first.service.manager, 'pm2');
const before = await response('one');
await writeNginx('two');
const second = await bootstrapPanel(context, true, system);
assert.equal(second.service.manager, 'pm2');
const after = await response('two');
assert.notEqual(after.pid, before.pid);
await stopPanel(context);
const list = await runProcess('pm2', ['jlist'], {
...options,
capture: true,
});
assert.deepEqual(JSON.parse(list.stdout), []);
},
);
+148
View File
@@ -0,0 +1,148 @@
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 net = require('node:net');
const { execFileSync, spawn } = require('node:child_process');
const { setTimeout: delay } = require('node:timers/promises');
const { createContext } = require('../../dist/internal/runtime/context');
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
test(
'real SSH subscriptions require the configured identity and pinned host key',
{ skip: process.env.QL_SSH_INTEGRATION !== '1', timeout: 30000 },
async (t) => {
assert.equal(process.platform, 'linux');
assert.equal(
process.getuid(),
0,
'Use only the disposable root SSH fixture container',
);
// sshd StrictModes rejects authorized_keys below world-writable /tmp.
const root = await fs.mkdtemp('/var/lib/ql-ssh-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.chmod(root, 0o755);
const run = (program, args) =>
execFileSync(program, args, { stdio: 'pipe' }).toString();
run('adduser', ['-D', '-H', '-s', '/bin/sh', 'qlfixture']);
run('passwd', ['-d', 'qlfixture']);
for (const name of ['host', 'identity', 'wrong'])
run('ssh-keygen', [
'-t',
'ed25519',
'-N',
'',
'-f',
path.join(root, name),
]);
const authorized = path.join(root, 'authorized_keys');
await fs.copyFile(path.join(root, 'identity.pub'), authorized);
await fs.chmod(authorized, 0o644);
const source = path.join(root, 'owner/repo');
await fs.mkdir(source, { recursive: true });
run('git', ['-C', source, 'init', '-b', 'selected']);
run('git', ['-C', source, 'config', 'user.name', 'Fixture']);
run('git', [
'-C',
source,
'config',
'user.email',
'fixture@example.invalid',
]);
await fs.writeFile(path.join(source, 'job.js'), 'selected SSH script');
run('git', ['-C', source, 'add', '.']);
run('git', ['-C', source, 'commit', '-m', 'fixture']);
run('chown', ['-R', 'qlfixture:qlfixture', path.join(root, 'owner')]);
const port = 22022;
const serverConfig = path.join(root, 'sshd_config');
await fs.writeFile(
serverConfig,
`Port ${port}\nListenAddress 127.0.0.1\nHostKey ${root}/host\nPidFile ${root}/sshd.pid\nAuthorizedKeysFile ${authorized}\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers qlfixture\nAllowTcpForwarding no\nX11Forwarding no\nUsePAM no\n`,
);
const server = spawn('/usr/sbin/sshd', ['-D', '-e', '-f', serverConfig], {
stdio: ['ignore', 'ignore', 'pipe'],
});
let diagnostic = '';
server.stderr.on('data', (chunk) => {
diagnostic = (diagnostic + chunk).slice(-2000);
});
t.after(async () => {
t.diagnostic(diagnostic);
if (server.exitCode === null && server.signalCode === null) {
const exited = new Promise((resolve) => server.once('exit', resolve));
server.kill('SIGTERM');
await exited;
}
});
let ready = false;
for (let i = 0; i < 100; i++) {
ready = await new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1');
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => resolve(false));
});
if (ready || server.exitCode !== null) break;
await delay(20);
}
assert.ok(ready, diagnostic);
const knownHosts = path.join(root, 'known_hosts');
const trusted = `[127.0.0.1]:${port} ${await fs.readFile(
path.join(root, 'host.pub'),
'utf8',
)}`;
const config = path.join(root, 'ssh_config');
const configure = async (key = 'identity') =>
fs.writeFile(
config,
`Host fixture\n HostName 127.0.0.1\n Port ${port}\n User qlfixture\n IdentityFile ${root}/${key}\n IdentityAgent none\n IdentitiesOnly yes\n BatchMode yes\n StrictHostKeyChecking yes\n UserKnownHostsFile ${knownHosts}\n GlobalKnownHostsFile /dev/null\n ConnectTimeout 3\n`,
);
for (const url of [`ssh://fixture${source}`, `fixture:${source}`]) {
await configure();
await fs.writeFile(knownHosts, trusted);
const panel = await fs.mkdtemp(path.join(root, 'panel-'));
const env = {
PATH: process.env.PATH,
GIT_SSH_COMMAND: `ssh -F ${config}`,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: os.devNull,
GIT_TERMINAL_PROMPT: '0',
};
const context = createContext({ root: panel }, env);
const input = {
url,
branch: 'selected',
autoAdd: false,
autoDelete: false,
};
const result = await syncRepository(context, input);
const job = path.join(
context.paths.dir_scripts,
result.repository,
'job.js',
);
const checkout = path.join(context.paths.dir_repo, result.repository);
const head = run('git', ['-C', checkout, 'rev-parse', 'HEAD']);
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
for (const failure of ['identity', 'host-key']) {
await configure(failure === 'identity' ? 'wrong' : 'identity');
await fs.writeFile(
knownHosts,
failure === 'host-key'
? `[127.0.0.1]:${port} ${await fs.readFile(
path.join(root, 'wrong.pub'),
'utf8',
)}`
: trusted,
);
await assert.rejects(syncRepository(context, input));
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
assert.equal(run('git', ['-C', checkout, 'rev-parse', 'HEAD']), head);
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
}
}
},
);
+101
View File
@@ -0,0 +1,101 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { fork } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { startPanel, stopPanel } = require('../../dist/internal/maintenance/operator');
const { runProcess } = require('../../dist/internal/runtime/process');
test(
'interrupted replacement stops the real new service and restores the old HTTP backend',
{ timeout: 60000 },
async (t) => {
for (const manager of ['node', 'pm2']) {
await t.test(manager, async (t) => {
const root = await fs.mkdtemp(
path.join(os.tmpdir(), 'ql-upgrade-real-'),
);
const env = {
...process.env,
TEST_ROOT: root,
QL_DIR: root,
HOME: root,
PM2_HOME: path.join(root, 'pm2'),
PATH: manager === 'node' ? '/nonexistent' : process.env.PATH,
};
const context = createContext({ root }, env);
let child;
t.after(async () => {
if (child && child.exitCode === null && child.signalCode === null)
child.kill('SIGKILL');
await stopPanel(context);
if (manager === 'pm2')
await runProcess('pm2', ['kill'], {
env,
output: () => {},
timeoutMs: 10000,
});
await fs.rm(root, { recursive: true, force: true });
});
for (const [directory, version] of [
['static', 'old'],
['staged', 'new'],
]) {
await fs.mkdir(path.join(root, directory, 'build'), {
recursive: true,
});
await fs.writeFile(
path.join(root, directory, 'build/app.js'),
`require('http').createServer((q,s)=>s.end(JSON.stringify({version:'${version}',pid:process.pid}))).listen(5811,'127.0.0.1')`,
);
}
await fs.writeFile(
path.join(root, 'ecosystem.config.js'),
'module.exports={apps:[{name:"upgrade-fixture",script:"static/build/app.js",exec_mode:"fork",instances:1}]}',
);
assert.equal((await startPanel(context)).manager, manager);
const old = await require('./service-response.cjs')('old');
assert.equal(old.version, 'old');
child = fork(path.join(__dirname, 'interrupted-reload.cjs'), [], {
env,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
let diagnostics = '';
child.stdout.on('data', (chunk) => (diagnostics += chunk));
child.stderr.on('data', (chunk) => (diagnostics += chunk));
const closed = new Promise((resolve) =>
child.on('close', (code, signal) => resolve({ code, signal })),
);
const ready = await Promise.race([
new Promise((resolve) => child.once('message', resolve)),
closed.then((result) => {
throw new Error(
`Premature exit ${JSON.stringify(result)}: ${diagnostics}`,
);
}),
]);
assert.equal(ready.ready, true);
assert.notEqual(ready.pid, old.pid);
child.kill('SIGTERM');
assert.deepEqual(
await closed,
{ code: 143, signal: null },
diagnostics,
);
const restored = await require('./service-response.cjs')('old');
assert.equal(restored.version, 'old');
assert.notEqual(restored.pid, ready.pid);
assert.throws(() => process.kill(ready.pid, 0), { code: 'ESRCH' });
assert.match(
await fs.readFile(path.join(root, 'static/build/app.js'), 'utf8'),
/version:'old'/,
);
assert.ok(
!(await fs.readdir(root)).some((name) => name.includes('ql-backup')),
);
});
}
},
);
+37
View File
@@ -0,0 +1,37 @@
// Assertions for the disposable online check/repair scenario in README.md.
const fs = require('node:fs');
const assert = require('node:assert/strict');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
const result = JSON.parse(
fs.readFileSync('/tmp/check-repair-result.json', 'utf8'),
);
assert.equal(result.code, 200);
assert.equal(result.data.after.panel.healthy, true);
assert.equal(result.data.after.backend.healthy, true);
assert.equal(result.data.service.manager, 'pm2');
assert.ok(result.data.restored.includes('/ql/data/config/task_before.sh'));
assert.deepEqual(
fs.readFileSync('/ql/data/config/task_before.sh'),
fs.readFileSync('/ql/sample/task.sample.sh'),
);
assert.deepEqual(
fs.readFileSync('/ql/data/config/config.sh'),
fs.readFileSync('/tmp/expected-config.sh'),
);
assert.deepEqual(
fs.readFileSync('/ql/data/scripts/sendNotify.js'),
fs.readFileSync('/ql/sample/notify.js'),
);
assert.deepEqual(
fs.readFileSync('/ql/data/scripts/notify.py'),
fs.readFileSync('/ql/sample/notify.py'),
);
console.log(
JSON.stringify({
healthy: true,
restoredHook: true,
preservedConfiguration: true,
refreshedNotifications: true,
manager: result.data.service.manager,
}),
);
+38
View File
@@ -0,0 +1,38 @@
// Used only after start --no-startup in a disposable container without its old entrypoint.
const assert = require('node:assert/strict');
const fs = require('node:fs');
assert.equal(process.env.QL_PANEL_INTEGRATION, '1');
(async () => {
const result = JSON.parse(fs.readFileSync('/tmp/start-result.json', 'utf8'));
assert.equal(result.code, 200);
assert.equal(result.data.mode, 'install');
assert.equal(result.data.startup, 'skipped');
assert.equal(result.data.service.manager, 'pm2');
const response = await fetch('http://127.0.0.1:5700/api/system', {
signal: AbortSignal.timeout(5000),
});
const system = await response.json();
assert.equal(system.code, 200);
assert.equal(system.data.isInitialized, false);
const html = await (
await fetch('http://127.0.0.1:5700/', { signal: AbortSignal.timeout(5000) })
).text();
assert.match(html, /<div\s+id=["']root["']/);
const saved = JSON.parse(fs.readFileSync('/root/.pm2/dump.pm2', 'utf8'));
assert.ok(saved.some((app) => app.name === 'qinglong'));
for (const file of ['config.sh', 'task_before.sh', 'task_after.sh'])
assert.ok(fs.existsSync(`/ql/data/config/${file}`));
console.log(
JSON.stringify({
install: true,
startupRegistration: result.data.startup,
healthy: true,
initialized: false,
pm2StateSaved: true,
configurationPrepared: true,
}),
);
})().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});