mirror of
https://github.com/whyour/qinglong.git
synced 2026-11-07 01:27:39 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12d2ea9cb | ||
|
|
7c7d8ab06b | ||
|
|
99a4d0eff8 |
@@ -6,6 +6,11 @@ on:
|
||||
- '.github/workflows/release-readiness.yml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'docker/Dockerfile*'
|
||||
- 'shell/node-runtime-probe.cjs'
|
||||
- 'scripts/verify-panel-package.cjs'
|
||||
- 'scripts/verify-image-node-layers.py'
|
||||
- 'test/back/node-runtime-probe.test.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -41,6 +46,13 @@ jobs:
|
||||
path: static/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
- name: Pack the verified npm archive for isolated runtime validation
|
||||
run: node scripts/verify-panel-package.cjs pack "$RUNNER_TEMP/npm-panel"
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: release-readiness-npm-panel
|
||||
path: ${{ runner.temp }}/npm-panel/
|
||||
if-no-files-found: error
|
||||
|
||||
images:
|
||||
needs: [regression, static]
|
||||
@@ -121,16 +133,38 @@ jobs:
|
||||
docker run --rm --network none --entrypoint node \
|
||||
qinglong-readiness:${{ matrix.image }} \
|
||||
-e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")'
|
||||
- name: Export native Debian image for isolated dev validation
|
||||
if: matrix.image == 'debian'
|
||||
run: |
|
||||
docker save qinglong-readiness:debian | gzip -1 > release-readiness-debian-image.tar.gz
|
||||
sha256sum release-readiness-debian-image.tar.gz > release-readiness-debian-image.sha256
|
||||
docker image inspect qinglong-readiness:debian > release-readiness-debian-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian'
|
||||
- name: Export ARMv6 image layers for Node regression checks
|
||||
if: startsWith(matrix.image, 'alpine')
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
name: release-readiness-debian-image
|
||||
path: release-readiness-debian-image.*
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm/v6
|
||||
build-args: |
|
||||
QL_URL=${{ steps.source.outputs.url }}
|
||||
QL_BRANCH=${{ steps.source.outputs.branch }}
|
||||
SOURCE_COMMIT=${{ steps.source.outputs.sha }}
|
||||
outputs: type=docker,dest=${{ runner.temp }}/node-layers-arm.tar
|
||||
push: false
|
||||
provenance: false
|
||||
sbom: false
|
||||
- name: Check Node binaries in every serialized image layer
|
||||
if: startsWith(matrix.image, 'alpine')
|
||||
run: |
|
||||
docker save qinglong-readiness:${{ matrix.image }} -o "$RUNNER_TEMP/node-layers-amd64.tar"
|
||||
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-amd64.tar" amd64
|
||||
python3 scripts/verify-image-node-layers.py "$RUNNER_TEMP/node-layers-arm.tar" arm
|
||||
rm "$RUNNER_TEMP/node-layers-amd64.tar" "$RUNNER_TEMP/node-layers-arm.tar"
|
||||
- name: Export native image for isolated dev validation
|
||||
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||
run: |
|
||||
docker save qinglong-readiness:${{ matrix.image }} | gzip -1 > release-readiness-${{ matrix.image }}-image.tar.gz
|
||||
sha256sum release-readiness-${{ matrix.image }}-image.tar.gz > release-readiness-${{ matrix.image }}-image.sha256
|
||||
docker image inspect qinglong-readiness:${{ matrix.image }} > release-readiness-${{ matrix.image }}-image.json
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: matrix.image == 'debian' || matrix.image == 'alpine'
|
||||
with:
|
||||
name: release-readiness-${{ matrix.image }}-image
|
||||
path: release-readiness-${{ matrix.image }}-image.*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"ql": "dist/npm/ql.js"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@whyour/qinglong-cli",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Authenticated remote management CLI for QingLong 2.x",
|
||||
"type": "commonjs",
|
||||
"bin": {
|
||||
|
||||
+16
-1
@@ -22,8 +22,14 @@ RUN set -x \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.11-alpine
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
||||
@@ -44,7 +50,8 @@ EXPOSE 5700
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||
set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
@@ -62,6 +69,10 @@ RUN set -x \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
@@ -86,6 +97,10 @@ COPY static/ /ql/static/
|
||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
|
||||
+16
-1
@@ -22,8 +22,14 @@ RUN set -x \
|
||||
npm_config_target_libc=musl \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Pin amd64 to upstream's musl runtime after native URL parser crashes with APK Node.
|
||||
FROM --platform=linux/amd64 node:24.18.1-alpine3.24@sha256:f70403e87646dc51b45295f4b8b70cdad0b63d2297c4c9899119b03f7af7a6b3 AS node-runtime-amd64
|
||||
|
||||
FROM python:3.10-alpine
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
|
||||
ARG QL_MAINTAINER="whyour"
|
||||
LABEL maintainer="${QL_MAINTAINER}"
|
||||
ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git
|
||||
@@ -44,7 +50,8 @@ EXPOSE 5700
|
||||
COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
|
||||
COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
|
||||
|
||||
RUN set -x \
|
||||
RUN --mount=type=bind,from=node-runtime-amd64,source=/usr/local/bin/node,target=/tmp/node-runtime-amd64 \
|
||||
set -x \
|
||||
&& apk update -f \
|
||||
&& apk upgrade \
|
||||
&& apk --no-cache add -f bash \
|
||||
@@ -62,6 +69,10 @@ RUN set -x \
|
||||
netcat-openbsd \
|
||||
unzip \
|
||||
npm \
|
||||
&& if [ "$TARGETARCH" = amd64 ]; then \
|
||||
cp /tmp/node-runtime-amd64 /usr/local/bin/node; \
|
||||
ln -sf /usr/local/bin/node /usr/bin/node; \
|
||||
fi \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& apk update \
|
||||
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
@@ -86,6 +97,10 @@ COPY static/ /ql/static/
|
||||
COPY docker/verify-build.cjs docker/build-manifest.cjs /tmp/
|
||||
COPY --from=builder /tmp/build/pnpm-lock.yaml /tmp/dependency-lock.yaml
|
||||
RUN cd ${QL_DIR} && node /tmp/verify-build.cjs /tmp/dependency-lock.yaml
|
||||
RUN if [ "$TARGETARCH" = arm ] && [ "${TARGETVARIANT:-}" = v6 ]; then \
|
||||
QEMU_CPU=arm1176 node --jitless ${QL_DIR}/shell/node-runtime-probe.cjs; \
|
||||
else node ${QL_DIR}/shell/node-runtime-probe.cjs; fi
|
||||
|
||||
|
||||
ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \
|
||||
PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \
|
||||
|
||||
+18
-6
@@ -1313,30 +1313,40 @@ def add_notify_function():
|
||||
|
||||
|
||||
def report_notification(channel, title, content, success):
|
||||
report_notifications([(channel, success)], title, content)
|
||||
|
||||
|
||||
def report_notifications(results, title, content):
|
||||
ql_dir = os.getenv("QL_DIR")
|
||||
if not ql_dir:
|
||||
if not ql_dir or not results:
|
||||
return
|
||||
payload = {"title": str(title or "")[:512], "content": str(content or "")[:8192],
|
||||
payload = [{"title": str(title or "")[:512], "content": str(content or "")[:8192],
|
||||
"success": success, "notifyType": channel,
|
||||
"scriptName": os.path.basename(__import__("sys").argv[0]), "taskId": os.getenv("ID", "")}
|
||||
for channel, success in results]
|
||||
code = """const fs=require('node:fs'),path=require('node:path');
|
||||
const report=require(path.join(process.env.QL_DIR,'shell/preload/notificationAudit.js'));
|
||||
report(JSON.parse(fs.readFileSync(0,'utf8'))).catch(()=>{});"""
|
||||
try:
|
||||
# One Node startup/TLS connection per send, with room for cold startup
|
||||
# before the reporter's bounded RPC deadline on busy Alpine hosts.
|
||||
subprocess.run(["node", "-e", code], input=json.dumps(payload), text=True,
|
||||
capture_output=True, timeout=3)
|
||||
capture_output=True, timeout=10)
|
||||
except Exception:
|
||||
pass # Audit availability never changes notification delivery.
|
||||
|
||||
|
||||
def audited_notification(mode, title, content):
|
||||
def audited_notification(mode, title, content, results=None):
|
||||
result = None
|
||||
try:
|
||||
result = mode(title, content)
|
||||
except Exception:
|
||||
result = False
|
||||
print(f"{mode.__name__} 推送失败")
|
||||
report_notification(mode.__name__, title, content, result is True)
|
||||
if results is None:
|
||||
report_notification(mode.__name__, title, content, result is True)
|
||||
else:
|
||||
results.append((mode.__name__, result is True))
|
||||
|
||||
|
||||
def send(title: str, content: str, ignore_default_config: bool = False, **kwargs):
|
||||
@@ -1362,12 +1372,14 @@ def send(title: str, content: str, ignore_default_config: bool = False, **kwargs
|
||||
content += "\n\n" + one() if hitokoto not in [False, "false"] else ""
|
||||
|
||||
notify_function = add_notify_function()
|
||||
results = []
|
||||
ts = [
|
||||
threading.Thread(target=audited_notification, args=(mode, title, content), name=mode.__name__)
|
||||
threading.Thread(target=audited_notification, args=(mode, title, content, results), name=mode.__name__)
|
||||
for mode in notify_function
|
||||
]
|
||||
[t.start() for t in ts]
|
||||
[t.join() for t in ts]
|
||||
report_notifications(results, title, content)
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Check serialized Docker layers, including files hidden by later deletions."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
archive, architecture = sys.argv[1:]
|
||||
assert architecture in {"amd64", "arm"}
|
||||
nodes = []
|
||||
with tarfile.open(archive) as image:
|
||||
manifests = json.load(image.extractfile("manifest.json"))
|
||||
assert len(manifests) == 1, "Expected one platform image"
|
||||
manifest = manifests[0]
|
||||
config = json.load(image.extractfile(manifest["Config"]))
|
||||
assert config["architecture"] == architecture
|
||||
for name in manifest["Layers"]:
|
||||
with tarfile.open(fileobj=image.extractfile(name), mode="r|*") as layer:
|
||||
for member in layer:
|
||||
filename = member.name.removeprefix("./")
|
||||
assert not (
|
||||
filename == "tmp/node-runtime-amd64" and member.size > 0
|
||||
), "Temporary amd64 Node remains in an immutable layer"
|
||||
if filename not in {"usr/local/bin/node", "usr/bin/node"}:
|
||||
continue
|
||||
if not member.isfile():
|
||||
continue
|
||||
with layer.extractfile(member) as binary:
|
||||
header = binary.read(20)
|
||||
assert header[:4] == b"\x7fELF", "Expected a native Node binary"
|
||||
digest = hashlib.sha256(header)
|
||||
while chunk := binary.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
endian = "little" if header[5] == 1 else "big"
|
||||
machine = int.from_bytes(header[18:20], endian)
|
||||
assert machine == {"amd64": 62, "arm": 40}[architecture], (
|
||||
"Wrong Node architecture in an image layer"
|
||||
)
|
||||
nodes.append({"path": filename, "size": member.size, "sha256": digest.hexdigest()})
|
||||
assert len(nodes) == 1, "Expected exactly one retained Node binary across all layers"
|
||||
assert nodes[0]["path"] == (
|
||||
"usr/local/bin/node" if architecture == "amd64" else "usr/bin/node"
|
||||
)
|
||||
print(json.dumps({"pass": True, "architecture": architecture, "layers": len(manifest["Layers"]), "nodes": nodes}))
|
||||
@@ -23,7 +23,7 @@ const run = (program, args, options = {}) => execute(program, args, {
|
||||
const requiredFiles = [
|
||||
'.env.example', 'back/protos/api.proto', 'ecosystem.config.js', 'version.yaml',
|
||||
'shell/upgrade.cjs', 'shell/install-bundle.cjs', 'shell/install-package.cjs',
|
||||
'shell/upgrade-lock.yaml', 'shell/start.sh', 'shell/start-simplify.sh',
|
||||
'shell/upgrade-lock.yaml', 'shell/node-runtime-probe.cjs', 'shell/start.sh', 'shell/start-simplify.sh',
|
||||
'shell/task.sh', 'shell/preload/client.js', 'shell/preload/client.py',
|
||||
'shell/preload/sitecustomize.js', 'shell/preload/sitecustomize.py',
|
||||
'shell/preload/esm-loader.mjs', 'shell/preload/notificationAudit.js',
|
||||
@@ -191,6 +191,12 @@ async function verifyPanel(input, output) {
|
||||
const globalRoot = (await run('npm', ['root', '--global', '--prefix', prefix], { cwd: temp, env })).stdout.trim();
|
||||
root = path.join(globalRoot, '@whyour/qinglong');
|
||||
env.QL_DIR = root;
|
||||
const runtimeProbes = [];
|
||||
const checkRuntime = async () => {
|
||||
const result = await run(process.execPath, [path.join(root, 'shell/node-runtime-probe.cjs')], { cwd: temp, env, timeout: 540000 });
|
||||
runtimeProbes.push(JSON.parse(result.stdout.trim()));
|
||||
};
|
||||
await checkRuntime();
|
||||
// Use a subprocess: cached candidate modules must not mask missing files
|
||||
// after old-package installation and the real npm overwrite below.
|
||||
await run(process.execPath, ['-e', `const root=process.argv[1],p=require('node:path');const m=require(root+'/package.json');for(const target of Object.values(m.bin))require('node:fs').accessSync(p.join(root,target));for(const name of ['platformBundle','upgradePrestart','panelUpgrade'])require(root+'/cli/src/internal/maintenance/'+name+'.cjs');require(root+'/shell/upgrade.cjs').verifyUpgrade(root,p.join(root,'static')).catch(e=>{console.error(e);process.exitCode=1});`, root], { env, cwd: temp });
|
||||
@@ -287,6 +293,7 @@ async function verifyPanel(input, output) {
|
||||
const applying = await api('update/system', 'PUT');
|
||||
await job(applying.id, 'completed');
|
||||
const afterOnline = await healthy();
|
||||
await checkRuntime();
|
||||
assert.notEqual(afterOnline.build.instanceId, original.build.instanceId);
|
||||
assert.equal(afterOnline.build.sourceCommit, fixture.sourceCommit);
|
||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||
@@ -302,6 +309,7 @@ async function verifyPanel(input, output) {
|
||||
assert.equal((await healthy()).build.instanceId, afterOnline.build.instanceId);
|
||||
await run(path.join(prefix, 'bin/ql'), ['reload', 'system'], { cwd: temp, env, timeout: 240000 });
|
||||
const afterOffline = await healthy();
|
||||
await checkRuntime();
|
||||
assert.notEqual(afterOffline.build.instanceId, afterOnline.build.instanceId);
|
||||
assert.equal(afterOffline.build.sourceCommit, fixture.sourceCommit);
|
||||
await fs.access(path.join(root, 'node_modules/.pnpm'));
|
||||
@@ -325,7 +333,7 @@ async function verifyPanel(input, output) {
|
||||
customRootEnvironmentPreservedAutomatically: true,
|
||||
officialPlatformBundle: { baseImage: fixture.baseImage, sourceCommit: fixture.sourceCommit, sha256: fixture.sha256 },
|
||||
migratedFromNpmToOfficialPnpmLayout: true,
|
||||
existingNodePreserved: true,
|
||||
existingNodePreserved: true, nativeUrlRuntimeProbes: runtimeProbes,
|
||||
frontendAndBackendStarted: true, checksumFailureKeptOldService: true,
|
||||
prestartBeforeReady: true, onlineActualRestart: true,
|
||||
offlinePreparationDidNotContactIndex: true, offlineBashActualRestart: true,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
// Run in new processes: a healthy parent can outlive a broken native runtime.
|
||||
function probeNodeRuntime(executable = process.execPath) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-url-'));
|
||||
const source = `
|
||||
const assert = require('node:assert/strict');
|
||||
const { pathToFileURL, fileURLToPath } = require('node:url');
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
assert.equal(new URL('/任务?q=' + i, 'https://example.com').hostname, 'example.com');
|
||||
assert.equal(new URL('https://例子.测试').hostname, 'xn--fsqu00a.xn--0zwm56d');
|
||||
assert.equal(new URL('http://[::1]:5700').port, '5700');
|
||||
const filename = '/tmp/中文 path/' + i + '.cjs';
|
||||
assert.equal(fileURLToPath(pathToFileURL(filename)), filename);
|
||||
}
|
||||
console.log('QINGLONG_NODE_URL_OK');
|
||||
`;
|
||||
const file = path.join(directory, 'probe.cjs');
|
||||
fs.writeFileSync(file, source);
|
||||
try {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const args = process.execArgv.includes('--jitless') ? ['--jitless'] : [];
|
||||
args.push(...(i % 2 ? [file] : ['-e', source]));
|
||||
const child = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
// Multi-architecture CI runs this same probe through QEMU.
|
||||
timeout: 120000,
|
||||
maxBuffer: 65536,
|
||||
env: { ...process.env, NODE_OPTIONS: '' },
|
||||
});
|
||||
assert.equal(
|
||||
child.status,
|
||||
0,
|
||||
`Node URL runtime probe failed (${i % 2 ? 'file' : 'eval'}): signal=${
|
||||
child.signal
|
||||
}; status=${child.status}; ${child.error?.message || child.stderr}`,
|
||||
);
|
||||
assert.match(child.stdout, /QINGLONG_NODE_URL_OK/);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
node: process.version,
|
||||
architecture: process.arch,
|
||||
ada: process.versions.ada,
|
||||
processes: 4,
|
||||
urlIterationsPerProcess: 1000,
|
||||
nodeSha256: createHash('sha256')
|
||||
.update(fs.readFileSync(executable))
|
||||
.digest('hex'),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { probeNodeRuntime };
|
||||
if (require.main === module) console.log(JSON.stringify(probeNodeRuntime()));
|
||||
@@ -27,14 +27,22 @@ module.exports = async function reportNotificationAudit(payload) {
|
||||
credentials,
|
||||
{ 'grpc.enable_http_proxy': 0 },
|
||||
);
|
||||
await new Promise((resolve) => {
|
||||
client.RecordNotifyLog(
|
||||
payload,
|
||||
new grpc.Metadata(),
|
||||
{ deadline: new Date(Date.now() + 1500) },
|
||||
() => resolve(),
|
||||
);
|
||||
});
|
||||
// Python sends all channel results together so they share startup and TLS.
|
||||
const records = Array.isArray(payload) ? payload : [payload];
|
||||
const deadline = new Date(Date.now() + 5000);
|
||||
await Promise.all(
|
||||
records.map(
|
||||
(record) =>
|
||||
new Promise((resolve) => {
|
||||
client.RecordNotifyLog(
|
||||
record,
|
||||
new grpc.Metadata(),
|
||||
{ deadline },
|
||||
() => resolve(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Missing certificates, old panels and offline services are best effort.
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { probeNodeRuntime } = require('../../shell/node-runtime-probe.cjs');
|
||||
|
||||
test('the retained Node handles URL, IDNA and file paths in fresh eval and file processes', () => {
|
||||
const result = probeNodeRuntime();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.processes, 4);
|
||||
assert.match(result.nodeSha256, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('a native child crash fails the runtime probe instead of accepting its healthy parent', (t) => {
|
||||
if (process.platform === 'win32') return t.skip('POSIX crash fixture');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-crash-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const executable = path.join(directory, 'node');
|
||||
fs.writeFileSync(executable, '#!/bin/sh\nkill -SEGV $$\n', { mode: 0o700 });
|
||||
assert.throws(
|
||||
() => probeNodeRuntime(executable),
|
||||
/Node URL runtime probe failed.*SIGSEGV/,
|
||||
);
|
||||
});
|
||||
|
||||
test('emulated Node probes have a bounded two-minute budget and reject a timeout', (t) => {
|
||||
const childProcess = require('node:child_process');
|
||||
const modulePath = require.resolve('../../shell/node-runtime-probe.cjs');
|
||||
let directory;
|
||||
const makeDirectory = fs.mkdtempSync;
|
||||
t.mock.method(fs, 'mkdtempSync', (...args) => {
|
||||
directory = makeDirectory(...args);
|
||||
return directory;
|
||||
});
|
||||
t.mock.method(childProcess, 'spawnSync', (_program, args, options) => {
|
||||
assert.equal(options.timeout, 120000);
|
||||
assert.equal(options.env.NODE_OPTIONS, '');
|
||||
return { status: null, signal: 'SIGTERM', error: Object.assign(new Error('probe timed out'), { code: 'ETIMEDOUT' }) };
|
||||
});
|
||||
delete require.cache[modulePath];
|
||||
t.after(() => { delete require.cache[modulePath]; });
|
||||
const probe = require(modulePath).probeNodeRuntime;
|
||||
assert.throws(() => probe(), /Node URL runtime probe failed.*SIGTERM.*probe timed out/);
|
||||
assert.ok(directory);
|
||||
assert.equal(fs.existsSync(directory), false, 'Timed-out probes must remove their temporary source files');
|
||||
});
|
||||
@@ -29,7 +29,9 @@ test('JS and Python script reports reach the same authenticated RPC with bounded
|
||||
server.addService(Api.service, {
|
||||
RecordNotifyLog: (call, callback) => {
|
||||
received.push(call.request);
|
||||
callback(null, { code: 200 });
|
||||
if (call.request.title === 'Concurrent cold startup')
|
||||
setTimeout(() => callback(null, { code: 200 }), 2000);
|
||||
else callback(null, { code: 200 });
|
||||
},
|
||||
});
|
||||
const credentials = grpc.ServerCredentials.createSsl(
|
||||
@@ -91,6 +93,46 @@ n.send('Python failure','body')
|
||||
);
|
||||
assert.ok(received.every((x) => x.taskId === '9'));
|
||||
assert.ok(!JSON.stringify(received).includes('private-password'));
|
||||
// A busy host can spend longer than the former 3s timeout starting Node.
|
||||
// All channels must still arrive, using one child and one TLS connection.
|
||||
const coldStart = path.join(data, 'cold-start.cjs');
|
||||
fs.writeFileSync(
|
||||
coldStart,
|
||||
'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,3500);',
|
||||
);
|
||||
const concurrent = `import importlib.util,sys,types,os,threading,json,subprocess
|
||||
sys.modules['requests']=types.SimpleNamespace()
|
||||
spec=importlib.util.spec_from_file_location('notify',os.path.join(os.environ['QL_DIR'],'sample/notify.py'))
|
||||
n=importlib.util.module_from_spec(spec);spec.loader.exec_module(n)
|
||||
barrier=threading.Barrier(3)
|
||||
def accepted(title,content):
|
||||
barrier.wait(timeout=5);return True
|
||||
def rejected(title,content):
|
||||
barrier.wait(timeout=5);raise RuntimeError('private-password')
|
||||
def no_result(title,content):
|
||||
barrier.wait(timeout=5)
|
||||
n.add_notify_function=lambda:[accepted,rejected,no_result]
|
||||
original=subprocess.run
|
||||
children=[]
|
||||
def tracked(*args,**kwargs):
|
||||
children.append(json.loads(kwargs['input']))
|
||||
return original(*args,**kwargs)
|
||||
n.subprocess.run=tracked
|
||||
n.send('Concurrent cold startup','body')
|
||||
assert len(children)==1,children
|
||||
assert len(children[0])==3,children
|
||||
`;
|
||||
await run('python3', ['-c', concurrent], {
|
||||
env: { ...env, NODE_OPTIONS: `--require=${coldStart}` },
|
||||
timeout: 14000,
|
||||
});
|
||||
assert.deepEqual(
|
||||
received
|
||||
.filter((x) => x.title === 'Concurrent cold startup')
|
||||
.map((x) => [x.notifyType, x.success])
|
||||
.sort(),
|
||||
[['accepted', true], ['no_result', false], ['rejected', false]],
|
||||
);
|
||||
// Missing certificates and an older/unavailable backend must not kill scripts.
|
||||
await run(
|
||||
process.execPath,
|
||||
@@ -110,5 +152,5 @@ n.send('Python failure','body')
|
||||
],
|
||||
{ env, timeout: 8000 },
|
||||
);
|
||||
assert.ok(Date.now() - started < 5000);
|
||||
assert.ok(Date.now() - started < 7000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user