@whyour/qinglong 2.21.0-13 → 2.21.0-15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docker/Dockerfile CHANGED
@@ -1,11 +1,26 @@
1
- FROM python:3.11-alpine3.18 AS builder
1
+ # Run Node package installation natively on the builder. Node/npm can spin at
2
+ # 100% CPU when Alpine s390x is emulated through QEMU.
3
+ FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
4
+
5
+ ARG TARGETARCH
6
+ ENV NPM_CONFIG_PREFIX=/opt/node-global
7
+ ENV PATH=/opt/node-global/bin:${PATH}
8
+
2
9
  COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
3
10
  RUN set -x \
4
- && apk update \
5
- && apk add nodejs npm git \
11
+ && apk add --no-cache git \
6
12
  && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
7
13
  && cd /tmp/build \
8
- && pnpm install --prod
14
+ && case "${TARGETARCH}" in \
15
+ amd64) NODE_ARCH=x64 ;; \
16
+ 386) NODE_ARCH=ia32 ;; \
17
+ ppc64le) NODE_ARCH=ppc64 ;; \
18
+ *) NODE_ARCH="${TARGETARCH}" ;; \
19
+ esac \
20
+ && npm_config_target_platform=linux \
21
+ npm_config_target_arch="${NODE_ARCH}" \
22
+ npm_config_target_libc=musl \
23
+ pnpm install --prod
9
24
 
10
25
  FROM python:3.11-alpine
11
26
 
@@ -26,8 +41,8 @@ VOLUME /ql/data
26
41
 
27
42
  EXPOSE 5700
28
43
 
29
- COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
30
- COPY --from=builder /usr/local/bin/. /usr/local/bin/
44
+ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
45
+ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
31
46
 
32
47
  RUN set -x \
33
48
  && apk update -f \
@@ -1,11 +1,26 @@
1
- FROM python:3.10-alpine3.18 AS builder
1
+ # Run Node package installation natively on the builder. Node/npm can spin at
2
+ # 100% CPU when Alpine s390x is emulated through QEMU.
3
+ FROM --platform=$BUILDPLATFORM node:18-alpine3.18 AS builder
4
+
5
+ ARG TARGETARCH
6
+ ENV NPM_CONFIG_PREFIX=/opt/node-global
7
+ ENV PATH=/opt/node-global/bin:${PATH}
8
+
2
9
  COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
3
10
  RUN set -x \
4
- && apk update \
5
- && apk add nodejs npm git \
11
+ && apk add --no-cache git \
6
12
  && npm i -g pnpm@8.3.1 pm2 ts-node typescript@5 \
7
13
  && cd /tmp/build \
8
- && pnpm install --prod
14
+ && case "${TARGETARCH}" in \
15
+ amd64) NODE_ARCH=x64 ;; \
16
+ 386) NODE_ARCH=ia32 ;; \
17
+ ppc64le) NODE_ARCH=ppc64 ;; \
18
+ *) NODE_ARCH="${TARGETARCH}" ;; \
19
+ esac \
20
+ && npm_config_target_platform=linux \
21
+ npm_config_target_arch="${NODE_ARCH}" \
22
+ npm_config_target_libc=musl \
23
+ pnpm install --prod
9
24
 
10
25
  FROM python:3.10-alpine
11
26
 
@@ -26,8 +41,8 @@ VOLUME /ql/data
26
41
 
27
42
  EXPOSE 5700
28
43
 
29
- COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/
30
- COPY --from=builder /usr/local/bin/. /usr/local/bin/
44
+ COPY --from=builder /opt/node-global/lib/node_modules/. /usr/local/lib/node_modules/
45
+ COPY --from=builder /opt/node-global/bin/. /usr/local/bin/
31
46
 
32
47
  RUN set -x \
33
48
  && apk update -f \
@@ -1,4 +1,6 @@
1
- FROM node:22-slim AS nodebuilder
1
+ # Node 20 Bookworm is the latest official Node image variant that covers the
2
+ # full Debian build matrix, including arm/v7, ppc64le, and s390x.
3
+ FROM node:20-bookworm-slim AS nodebuilder
2
4
 
3
5
  FROM python:3.11-slim-bookworm AS builder
4
6
  COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
@@ -1,4 +1,6 @@
1
- FROM node:22-slim AS nodebuilder
1
+ # Node 20 Bookworm is the latest official Node image variant that covers the
2
+ # full Debian build matrix, including arm/v7, ppc64le, and s390x.
3
+ FROM node:20-bookworm-slim AS nodebuilder
2
4
 
3
5
  FROM python:3.10-slim-bookworm AS builder
4
6
  COPY package.json .npmrc pnpm-lock.yaml /tmp/build/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@whyour/qinglong",
3
3
  "packageManager": "pnpm@8.3.1",
4
- "version": "2.21.0-13",
4
+ "version": "2.21.0-15",
5
5
  "description": "Timed task management platform supporting Python3, JavaScript, Shell, Typescript",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  "start:front": "max dev",
19
19
  "build:front": "max build",
20
20
  "build:back": "tsc -p back/tsconfig.json",
21
+ "test:race": "node -r ts-node/register test/race-conditions.test.js",
21
22
  "panel": "npm run build:back && node static/build/app.js",
22
23
  "gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false",
23
24
  "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
@@ -65,7 +66,7 @@
65
66
  }
66
67
  },
67
68
  "overrides": {
68
- "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
69
+ "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0",
69
70
  "@codemirror/state": "6.5.4",
70
71
  "@codemirror/view": "6.39.16"
71
72
  }
@@ -109,7 +110,7 @@
109
110
  "request-ip": "3.3.0",
110
111
  "sequelize": "^6.37.5",
111
112
  "sockjs": "^0.3.24",
112
- "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3",
113
+ "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.1.0",
113
114
  "toad-scheduler": "^3.0.1",
114
115
  "typedi": "^0.10.0",
115
116
  "undici": "^7.9.0",
@@ -232,13 +232,34 @@ async function readDirs(dir, baseDir = '', blacklist = [], sort = dirSort) {
232
232
  const result = [];
233
233
  for (const file of files) {
234
234
  const subPath = path.join(dir, file);
235
- const stats = await fs.lstat(subPath);
235
+ let stats;
236
+ try {
237
+ stats = await fs.lstat(subPath);
238
+ }
239
+ catch (error) {
240
+ // Files can disappear between readdir and lstat while logs are pruned.
241
+ // Skip only that expected race and keep surfacing all other I/O errors.
242
+ if (error.code === 'ENOENT') {
243
+ continue;
244
+ }
245
+ throw error;
246
+ }
236
247
  const key = path.join(relativePath, file);
237
248
  if (blacklist.includes(file) || stats.isSymbolicLink()) {
238
249
  continue;
239
250
  }
240
251
  if (stats.isDirectory()) {
241
- const children = await readDirs(subPath, baseDir, blacklist, sort);
252
+ let children;
253
+ try {
254
+ children = await readDirs(subPath, baseDir, blacklist, sort);
255
+ }
256
+ catch (error) {
257
+ // A directory may also be removed after lstat but before recursion.
258
+ if (error.code === 'ENOENT') {
259
+ continue;
260
+ }
261
+ throw error;
262
+ }
242
263
  result.push({
243
264
  title: file,
244
265
  key,
@@ -40,7 +40,7 @@ const path_1 = __importStar(require("path"));
40
40
  const config_1 = __importDefault(require("../config"));
41
41
  const util_1 = require("../config/util");
42
42
  const i18n_1 = require("../shared/i18n");
43
- const undici_1 = require("undici");
43
+ const sampleConfig_1 = require("./sampleConfig");
44
44
  let ConfigService = class ConfigService {
45
45
  constructor() { }
46
46
  async getFile(filePath, res) {
@@ -65,8 +65,7 @@ let ConfigService = class ConfigService {
65
65
  return res.send({ code: 403, message: (0, i18n_1.t)('文件无法访问') });
66
66
  }
67
67
  if (filePath.startsWith('sample/')) {
68
- const res = await (0, undici_1.request)(`https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`);
69
- content = await res.body.text();
68
+ content = await (0, sampleConfig_1.loadSampleFile)(filePath);
70
69
  }
71
70
  else if (filePath.startsWith('data/scripts/')) {
72
71
  content = await (0, util_1.getFileContentByName)((0, path_1.join)(config_1.default.rootPath, filePath));
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadSampleFile = void 0;
7
+ const promises_1 = require("fs/promises");
8
+ const path_1 = require("path");
9
+ const undici_1 = require("undici");
10
+ const config_1 = __importDefault(require("../config"));
11
+ const util_1 = require("../config/util");
12
+ const logger_1 = __importDefault(require("../loaders/logger"));
13
+ const sampleRequestDispatcher = new undici_1.RetryAgent(new undici_1.Agent({
14
+ connect: { timeout: 10000 },
15
+ headersTimeout: 10000,
16
+ bodyTimeout: 10000,
17
+ keepAliveTimeout: 10000,
18
+ keepAliveMaxTimeout: 30000,
19
+ }), {
20
+ maxRetries: 2,
21
+ minTimeout: 200,
22
+ maxTimeout: 1000,
23
+ timeoutFactor: 2,
24
+ statusCodes: [429, 500, 502, 503, 504],
25
+ errorCodes: [
26
+ 'ECONNRESET',
27
+ 'ECONNREFUSED',
28
+ 'ENOTFOUND',
29
+ 'ENETDOWN',
30
+ 'ENETUNREACH',
31
+ 'EHOSTDOWN',
32
+ 'EHOSTUNREACH',
33
+ 'EPIPE',
34
+ 'UND_ERR_CONNECT_TIMEOUT',
35
+ 'UND_ERR_HEADERS_TIMEOUT',
36
+ 'UND_ERR_BODY_TIMEOUT',
37
+ 'UND_ERR_SOCKET',
38
+ ],
39
+ });
40
+ const pendingSampleRequests = new Map();
41
+ async function loadSampleFile(filePath) {
42
+ const pendingRequest = pendingSampleRequests.get(filePath);
43
+ if (pendingRequest) {
44
+ return pendingRequest;
45
+ }
46
+ const requestPromise = (async () => {
47
+ const remoteUrl = `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`;
48
+ try {
49
+ const response = await (0, undici_1.request)(remoteUrl, {
50
+ dispatcher: sampleRequestDispatcher,
51
+ maxRedirections: 3,
52
+ });
53
+ const content = await response.body.text();
54
+ if (response.statusCode < 200 || response.statusCode >= 300) {
55
+ throw new Error(`GitLab returned HTTP ${response.statusCode} for ${filePath}`);
56
+ }
57
+ return content;
58
+ }
59
+ catch (error) {
60
+ logger_1.default.warn(`[config] Failed to fetch ${filePath}, using bundled sample: ${(0, util_1.errStack)(error)}`);
61
+ return (0, promises_1.readFile)((0, path_1.join)(config_1.default.rootPath, filePath), 'utf8');
62
+ }
63
+ })();
64
+ pendingSampleRequests.set(filePath, requestPromise);
65
+ try {
66
+ return await requestPromise;
67
+ }
68
+ finally {
69
+ pendingSampleRequests.delete(filePath);
70
+ }
71
+ }
72
+ exports.loadSampleFile = loadSampleFile;
73
+ //# sourceMappingURL=sampleConfig.js.map
@@ -0,0 +1,175 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+
6
+ require('reflect-metadata');
7
+
8
+ async function testReadDirsSkipsDisappearedEntries() {
9
+ const temporaryRoot = await fs.mkdtemp(
10
+ path.join(os.tmpdir(), 'qinglong-read-dirs-'),
11
+ );
12
+ const disappearingFile = path.join(temporaryRoot, 'disappearing.log');
13
+ const disappearingDirectory = path.join(temporaryRoot, 'disappearing-dir');
14
+ const originalLstat = fs.lstat;
15
+ const originalReaddir = fs.readdir;
16
+
17
+ await fs.writeFile(path.join(temporaryRoot, 'stable.log'), 'stable');
18
+ await fs.writeFile(disappearingFile, 'gone');
19
+ await fs.mkdir(disappearingDirectory);
20
+
21
+ fs.lstat = async (filePath, ...args) => {
22
+ if (filePath === disappearingFile) {
23
+ const error = new Error('file disappeared');
24
+ error.code = 'ENOENT';
25
+ throw error;
26
+ }
27
+ return originalLstat(filePath, ...args);
28
+ };
29
+ fs.readdir = async (directoryPath, ...args) => {
30
+ if (directoryPath === disappearingDirectory) {
31
+ const error = new Error('directory disappeared');
32
+ error.code = 'ENOENT';
33
+ throw error;
34
+ }
35
+ return originalReaddir(directoryPath, ...args);
36
+ };
37
+
38
+ try {
39
+ const { readDirs } = require('../back/config/util');
40
+ const entries = await readDirs(temporaryRoot, temporaryRoot);
41
+ assert.deepEqual(
42
+ entries.map((entry) => entry.title),
43
+ ['stable.log'],
44
+ );
45
+ } finally {
46
+ fs.lstat = originalLstat;
47
+ fs.readdir = originalReaddir;
48
+ await fs.rm(temporaryRoot, { recursive: true, force: true });
49
+ }
50
+ }
51
+
52
+ async function testReadDirsPreservesUnexpectedErrors() {
53
+ const temporaryRoot = await fs.mkdtemp(
54
+ path.join(os.tmpdir(), 'qinglong-read-dirs-error-'),
55
+ );
56
+ const blockedFile = path.join(temporaryRoot, 'blocked.log');
57
+ const originalLstat = fs.lstat;
58
+
59
+ await fs.writeFile(blockedFile, 'blocked');
60
+ fs.lstat = async (filePath, ...args) => {
61
+ if (filePath === blockedFile) {
62
+ const error = new Error('permission denied');
63
+ error.code = 'EACCES';
64
+ throw error;
65
+ }
66
+ return originalLstat(filePath, ...args);
67
+ };
68
+
69
+ try {
70
+ const { readDirs } = require('../back/config/util');
71
+ await assert.rejects(
72
+ readDirs(temporaryRoot, temporaryRoot),
73
+ (error) => error.code === 'EACCES',
74
+ );
75
+ } finally {
76
+ fs.lstat = originalLstat;
77
+ await fs.rm(temporaryRoot, { recursive: true, force: true });
78
+ }
79
+ }
80
+
81
+ async function testSampleRequestsAreCoalescedAndFallbackLocally() {
82
+ const undici = require('undici');
83
+ const originalRequest = undici.request;
84
+ let releaseRequest;
85
+ let requestCount = 0;
86
+
87
+ undici.request = async () => {
88
+ requestCount += 1;
89
+ await new Promise((resolve) => {
90
+ releaseRequest = resolve;
91
+ });
92
+ return {
93
+ statusCode: 200,
94
+ body: { text: async () => 'remote sample' },
95
+ };
96
+ };
97
+
98
+ const { loadSampleFile } = require('../back/services/sampleConfig');
99
+ const pendingLoads = [
100
+ loadSampleFile('sample/config.sample.sh'),
101
+ loadSampleFile('sample/config.sample.sh'),
102
+ loadSampleFile('sample/config.sample.sh'),
103
+ ];
104
+
105
+ await new Promise((resolve) => setImmediate(resolve));
106
+ assert.equal(requestCount, 1);
107
+ releaseRequest();
108
+ assert.deepEqual(await Promise.all(pendingLoads), [
109
+ 'remote sample',
110
+ 'remote sample',
111
+ 'remote sample',
112
+ ]);
113
+
114
+ const temporaryRoot = await fs.mkdtemp(
115
+ path.join(os.tmpdir(), 'qinglong-sample-fallback-'),
116
+ );
117
+ const bundledSample = path.join(temporaryRoot, 'sample', 'config.sample.sh');
118
+ const config = require('../back/config').default;
119
+ const originalRootPath = config.rootPath;
120
+
121
+ await fs.mkdir(path.dirname(bundledSample), { recursive: true });
122
+ await fs.writeFile(bundledSample, 'bundled sample');
123
+ config.rootPath = temporaryRoot;
124
+ undici.request = async () => {
125
+ const error = new Error('other side closed');
126
+ error.code = 'UND_ERR_SOCKET';
127
+ throw error;
128
+ };
129
+
130
+ try {
131
+ assert.equal(
132
+ await loadSampleFile('sample/config.sample.sh'),
133
+ 'bundled sample',
134
+ );
135
+
136
+ undici.request = async () => ({
137
+ statusCode: 503,
138
+ body: { text: async () => 'upstream unavailable' },
139
+ });
140
+ assert.equal(
141
+ await loadSampleFile('sample/config.sample.sh'),
142
+ 'bundled sample',
143
+ );
144
+ } finally {
145
+ undici.request = originalRequest;
146
+ config.rootPath = originalRootPath;
147
+ await fs.rm(temporaryRoot, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ async function main() {
152
+ const testRoot = await fs.mkdtemp(
153
+ path.join(os.tmpdir(), 'qinglong-test-root-'),
154
+ );
155
+ process.env.QL_DIR = testRoot;
156
+ await fs.writeFile(path.join(testRoot, '.env'), 'NODE_ENV=test\n');
157
+ await fs.mkdir(path.join(testRoot, 'data', 'syslog'), { recursive: true });
158
+
159
+ try {
160
+ await testReadDirsSkipsDisappearedEntries();
161
+ await testReadDirsPreservesUnexpectedErrors();
162
+ await testSampleRequestsAreCoalescedAndFallbackLocally();
163
+ console.log('race condition regression tests passed');
164
+ } finally {
165
+ const loggerModule =
166
+ require.cache[require.resolve('../back/loaders/logger')];
167
+ loggerModule?.exports?.default?.close();
168
+ await fs.rm(testRoot, { recursive: true, force: true });
169
+ }
170
+ }
171
+
172
+ main().catch((error) => {
173
+ console.error(error);
174
+ process.exitCode = 1;
175
+ });