@synopackageland/cli 0.41.9 → 0.41.11
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/CHANGELOG.md +29 -0
- package/README.md +61 -2
- package/dist/cli.js +2 -4
- package/dist/compose/registry-mirrors.d.ts +3 -0
- package/dist/compose/registry-mirrors.js +38 -0
- package/dist/e2e/app-open.d.ts +5 -0
- package/dist/e2e/dsm.d.ts +3 -1
- package/dist/e2e/dsm.js +109 -7
- package/dist/error-registry.js +8 -0
- package/dist/host/catalog-install.d.ts +12 -3
- package/dist/host/catalog-install.js +202 -82
- package/dist/host/container-pull.d.ts +18 -0
- package/dist/host/container-pull.js +120 -0
- package/dist/host/dsm-client.js +3 -0
- package/dist/host/dsm-compound.js +10 -3
- package/dist/host/package-commands.js +4 -0
- package/dist/host/package-diagnostics.d.ts +5 -0
- package/dist/host/package-diagnostics.js +62 -0
- package/dist/images/pin.js +6 -16
- package/dist/log.js +5 -8
- package/dist/nas/auth.js +1 -1
- package/dist/nas/connection.js +1 -1
- package/dist/nas/http.d.ts +1 -1
- package/dist/nas/http.js +2 -2
- package/dist/nas/store.js +1 -1
- package/dist/nas/timeouts.d.ts +8 -0
- package/dist/nas/timeouts.js +25 -0
- package/dist/nas/tls.js +2 -1
- package/dist/nas/transport.d.ts +2 -2
- package/dist/nas/transport.js +11 -6
- package/dist/standalone/compose.js +2 -4
- package/package.json +1 -1
- package/skills/SKILL.md +6 -2
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { CtlError } from '../ctl/errors.js';
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { terminalSafe } from '../terminal-text.js';
|
|
4
|
+
import { pullContainerImages } from './container-pull.js';
|
|
5
|
+
import { savePullFailure } from './package-diagnostics.js';
|
|
3
6
|
import { dsmFetch } from "./dsm-http.js";
|
|
4
7
|
import { assertDsmCompoundResponse } from "./dsm-compound.js";
|
|
8
|
+
import { dsmRequestTimeoutMs, dsmOperationTimeoutMs, dsmInstallTimeoutMs, dsmUninstallTimeoutMs, dsmPollIntervalMs, isHostTimeout } from '../nas/timeouts.js';
|
|
5
9
|
function normalizeDeppkgs(deppkgs) {
|
|
6
10
|
if (typeof deppkgs === "string") {
|
|
7
11
|
const match = /^([^><=]+)(>=|>|<=|<|==)(.+)$/.exec(deppkgs.trim());
|
|
@@ -25,13 +29,13 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
|
|
|
25
29
|
const query = new URLSearchParams(params);
|
|
26
30
|
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
|
|
27
31
|
headers: { cookie: cookieHeader },
|
|
28
|
-
});
|
|
32
|
+
}, options.timeoutMs);
|
|
29
33
|
const body = (await response.json());
|
|
30
34
|
const errorCode = body.error?.code;
|
|
31
35
|
if (!body.success &&
|
|
32
36
|
!options.allowFailure &&
|
|
33
37
|
!(errorCode && options.allowErrorCodes?.includes(errorCode))) {
|
|
34
|
-
throw new
|
|
38
|
+
throw new CtlError('DSM_API_FAILED', `DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}${typeof body.error?.description === "string" ? ` ${body.error.description}` : ""}`, 'Check DSM permissions and Package Center');
|
|
35
39
|
}
|
|
36
40
|
return body;
|
|
37
41
|
}
|
|
@@ -70,7 +74,8 @@ export function packageStateFromRecord(value) {
|
|
|
70
74
|
const installedPath = stringValue(installedInfo.path, match.installedPath);
|
|
71
75
|
const version = stringValue(match.version);
|
|
72
76
|
const status = stringValue(additional.status, match.rawStatus, match.status);
|
|
73
|
-
const error = packageError(additional.error ?? match.error)
|
|
77
|
+
const error = packageError({ ...record(additional.error ?? match.error),
|
|
78
|
+
worker_msg: record(additional.error ?? match.error).worker_msg ?? record(additional.error ?? match.error).workerMsg ?? additional.worker_msg ?? match.worker_msg });
|
|
74
79
|
const state = {
|
|
75
80
|
installed: Boolean(installedPath) || match.installed === true,
|
|
76
81
|
};
|
|
@@ -110,7 +115,7 @@ export function packageStateFromListPayload(payload, packageId) {
|
|
|
110
115
|
}
|
|
111
116
|
return packageStateFromRecord(match);
|
|
112
117
|
}
|
|
113
|
-
export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId) {
|
|
118
|
+
export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId, timeoutMs) {
|
|
114
119
|
try {
|
|
115
120
|
const body = await dsmWebApi(dsmOrigin, cookieHeader, {
|
|
116
121
|
api: "SYNO.Core.Package",
|
|
@@ -118,7 +123,7 @@ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packag
|
|
|
118
123
|
method: "list",
|
|
119
124
|
additional: JSON.stringify(["status", "installed_info"]),
|
|
120
125
|
ignore_hidden: "false",
|
|
121
|
-
});
|
|
126
|
+
}, { timeoutMs });
|
|
122
127
|
return packageStateFromListPayload(body, packageId);
|
|
123
128
|
}
|
|
124
129
|
catch (error) {
|
|
@@ -133,89 +138,143 @@ function installStatusIsBroken(status) {
|
|
|
133
138
|
}
|
|
134
139
|
return /(broken|error|fail|150)/i.test(status);
|
|
135
140
|
}
|
|
141
|
+
function packageFailure(state) {
|
|
142
|
+
return installStatusIsBroken(state.status) || state.statusCode === 150 ||
|
|
143
|
+
Boolean(state.statusCode && /broken|error|fail|corruption/i.test(state.statusDescription ?? '')) ||
|
|
144
|
+
Boolean(state.error && (state.error.code !== undefined ? state.error.code !== 0 : state.error.description));
|
|
145
|
+
}
|
|
146
|
+
function failureDetail(state) {
|
|
147
|
+
return [state.error?.code ?? state.statusCode, state.error?.description ?? state.statusDescription,
|
|
148
|
+
...(state.error?.workerMsg ?? []).map(value => typeof value === 'string' ? value : JSON.stringify(value))].filter(value => value !== undefined).join(' ');
|
|
149
|
+
}
|
|
150
|
+
function installInProgress(state) {
|
|
151
|
+
return /^(installing|upgrading|starting)$/i.test(state.status ?? '');
|
|
152
|
+
}
|
|
153
|
+
function installBudgetExpired(options) {
|
|
154
|
+
const target = options.expectedVersion ? ` to reach version ${options.expectedVersion}` : ' to install from catalog';
|
|
155
|
+
return new CtlError('DSM_INSTALL_TIMEOUT', `Timed out waiting for ${options.packageId}${target}: overall install budget exhausted; DSM may still be installing/starting.`, 'Check Package Center state before retrying; the install was not replayed');
|
|
156
|
+
}
|
|
136
157
|
export async function waitForInstalledVersion(readState, options) {
|
|
137
|
-
const deadline = Date.now() + (options.deadlineMs ??
|
|
138
|
-
const pollMs = options.pollMs ??
|
|
158
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
159
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
160
|
+
let transitioned = !options.requireTransition;
|
|
139
161
|
while (Date.now() < deadline) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
let state;
|
|
163
|
+
try {
|
|
164
|
+
state = await readState(Math.max(1, deadline - Date.now()));
|
|
143
165
|
}
|
|
144
|
-
|
|
145
|
-
if (!
|
|
146
|
-
|
|
147
|
-
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (!isHostTimeout(error))
|
|
168
|
+
throw error;
|
|
169
|
+
options.onProgress?.(`${options.packageId}: status request timed out; still installing/starting…`);
|
|
148
170
|
}
|
|
149
|
-
|
|
171
|
+
if (state && !installInProgress(state) && packageFailure(state)) {
|
|
172
|
+
throw new CtlError('DSM_INSTALL_FAILED', `DSM reports package ${options.packageId} install did not complete (Package Center status ${state.status ?? 'unknown'}): ${failureDetail(state)}`.trim(), 'Check the DSM Package Center install/start log');
|
|
173
|
+
}
|
|
174
|
+
const running = /^(run|running)$/i.test(state?.status ?? '');
|
|
175
|
+
if (state && (!state.installed || (state.status !== undefined && !running) ||
|
|
176
|
+
(state.version !== undefined && state.version !== options.expectedVersion)))
|
|
177
|
+
transitioned = true;
|
|
178
|
+
if (Date.now() >= deadline)
|
|
179
|
+
break;
|
|
180
|
+
if (state?.installed && !/installing|upgrading|starting/i.test(state.status ?? '') &&
|
|
181
|
+
(!options.expectedVersion || state.version === options.expectedVersion) &&
|
|
182
|
+
(!options.requireRunning || running) && transitioned)
|
|
183
|
+
return;
|
|
184
|
+
options.onProgress?.(`${options.packageId}: installing/starting… (DSM status ${state?.status ?? 'unavailable'})`);
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
150
186
|
}
|
|
151
|
-
throw
|
|
152
|
-
? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
|
|
153
|
-
: `Timed out waiting for ${options.packageId} to install from catalog`);
|
|
187
|
+
throw installBudgetExpired(options);
|
|
154
188
|
}
|
|
155
189
|
function packageStatusIsStopped(state) {
|
|
156
190
|
const status = state?.status?.trim().toLowerCase();
|
|
157
191
|
return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
|
|
158
192
|
}
|
|
159
|
-
async function waitForPackageStopped(readState, options) {
|
|
160
|
-
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
161
|
-
const pollMs = options.pollMs ?? 1000;
|
|
162
|
-
while (Date.now() < deadline) {
|
|
163
|
-
const state = await readState();
|
|
164
|
-
if (packageStatusIsStopped(state)) {
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
168
|
-
}
|
|
169
|
-
throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
|
|
170
|
-
}
|
|
171
|
-
async function waitForPackageUninstalled(readState, options) {
|
|
172
|
-
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
173
|
-
const pollMs = options.pollMs ?? 1000;
|
|
174
|
-
while (Date.now() < deadline) {
|
|
175
|
-
const state = await readState();
|
|
176
|
-
if (state?.installed === false) {
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
180
|
-
}
|
|
181
|
-
throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
|
|
182
|
-
}
|
|
183
193
|
function uninstallFailure(packageId, error) {
|
|
184
194
|
const detail = error instanceof Error ? error.message : String(error);
|
|
195
|
+
if (error instanceof CtlError)
|
|
196
|
+
return new CtlError(error.code, `Failed to uninstall ${packageId}: ${detail}`, error.hint, error.exitCode);
|
|
185
197
|
const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
|
|
186
198
|
failure.code = "CATALOG_INSTALL_FAILED";
|
|
187
199
|
return failure;
|
|
188
200
|
}
|
|
189
201
|
export async function uninstallPackageFromDsm(options) {
|
|
202
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmUninstallTimeoutMs());
|
|
203
|
+
const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
|
|
204
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
205
|
+
const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
|
|
206
|
+
let phase = 'checking package state';
|
|
207
|
+
let previousFailure;
|
|
208
|
+
const expired = () => new CtlError('DSM_UNINSTALL_TIMEOUT', `Package ${options.packageId}: overall uninstall budget exhausted while ${phase}; DSM may still be stopping/uninstalling.`, 'Check Package Center before retrying; stop/uninstall requests were not replayed');
|
|
209
|
+
const remaining = (limit) => {
|
|
210
|
+
if (Date.now() >= deadline)
|
|
211
|
+
throw expired();
|
|
212
|
+
return Math.min(limit, deadline - Date.now());
|
|
213
|
+
};
|
|
214
|
+
const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
|
|
190
215
|
try {
|
|
191
216
|
const sid = extractSid(options.cookieHeader);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
217
|
+
const wait = async (done) => {
|
|
218
|
+
while (Date.now() < deadline) {
|
|
219
|
+
let state;
|
|
220
|
+
try {
|
|
221
|
+
state = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (!isHostTimeout(error))
|
|
225
|
+
throw error;
|
|
226
|
+
progress(`${options.packageId}: status request timed out; ${phase}…`);
|
|
227
|
+
}
|
|
228
|
+
if (state && Date.now() < deadline) {
|
|
229
|
+
if (done(state))
|
|
230
|
+
return state;
|
|
231
|
+
if (packageFailure(state) && !(phase === 'uninstalling' && previousFailure === failureDetail(state)))
|
|
232
|
+
throw new CtlError('DSM_UNINSTALL_FAILED', `DSM reports ${options.packageId} failed while ${phase}: ${failureDetail(state) || state.status}`, 'Check the DSM Package Center stop/uninstall log');
|
|
233
|
+
}
|
|
234
|
+
progress(`${options.packageId}: ${phase}… (DSM status ${state?.status ?? 'unavailable'})`);
|
|
235
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
|
|
236
|
+
}
|
|
237
|
+
throw expired();
|
|
238
|
+
};
|
|
239
|
+
const dispatch = async (api, method) => {
|
|
240
|
+
progress(`${options.packageId}: ${phase}…`);
|
|
241
|
+
try {
|
|
242
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
243
|
+
api, version: '1', method, id: jsonApiParam(options.packageId), _sid: sid,
|
|
244
|
+
...(method === 'uninstall' ? { extra_values: jsonApiParam('{}') } : {}),
|
|
245
|
+
}, { timeoutMs: remaining(operationMs) });
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (!isHostTimeout(error))
|
|
249
|
+
throw error;
|
|
250
|
+
progress(`${options.packageId}: ${method} request timed out; checking DSM state without replay…`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const before = await wait(() => true);
|
|
254
|
+
if (!before.installed)
|
|
255
|
+
return;
|
|
256
|
+
const broken = packageFailure(before) && !installInProgress(before);
|
|
257
|
+
if (broken) {
|
|
258
|
+
previousFailure = failureDetail(before);
|
|
259
|
+
progress(`${options.packageId}: broken package (${previousFailure || before.status}); uninstalling directly without stop…`);
|
|
205
260
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await
|
|
261
|
+
if (!broken && !packageStatusIsStopped(before)) {
|
|
262
|
+
phase = 'stopping';
|
|
263
|
+
await dispatch('SYNO.Core.Package.Control', 'stop');
|
|
264
|
+
const stopped = await wait(state => !state.installed || packageStatusIsStopped(state));
|
|
265
|
+
if (!stopped.installed)
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
phase = 'uninstalling';
|
|
269
|
+
await dispatch('SYNO.Core.Package.Uninstallation', 'uninstall');
|
|
270
|
+
await wait(state => !state.installed);
|
|
215
271
|
}
|
|
216
272
|
catch (error) {
|
|
217
273
|
throw uninstallFailure(options.packageId, error);
|
|
218
274
|
}
|
|
275
|
+
finally {
|
|
276
|
+
clearInterval(heartbeat);
|
|
277
|
+
}
|
|
219
278
|
}
|
|
220
279
|
export function catalogInstallationRequests(entry, installed, sid, volumePath) {
|
|
221
280
|
const install = {
|
|
@@ -297,7 +356,7 @@ export function catalogCompoundSteps(uploaded, entry, options) {
|
|
|
297
356
|
export function catalogUpgradeCompoundSteps(uploaded, entry, extraValues) {
|
|
298
357
|
return catalogCompoundSteps(uploaded, entry, { blupgrade: true, extraValues });
|
|
299
358
|
}
|
|
300
|
-
async function uploadLocalSpk(options, sid) {
|
|
359
|
+
async function uploadLocalSpk(options, sid, timeoutMs) {
|
|
301
360
|
const bytes = readFileSync(options.spkPath);
|
|
302
361
|
const form = new FormData();
|
|
303
362
|
form.append("api", "SYNO.Core.Package.Installation");
|
|
@@ -309,7 +368,7 @@ async function uploadLocalSpk(options, sid) {
|
|
|
309
368
|
method: "POST",
|
|
310
369
|
headers: { cookie: options.cookieHeader },
|
|
311
370
|
body: form,
|
|
312
|
-
});
|
|
371
|
+
}, timeoutMs);
|
|
313
372
|
const body = (await uploadResponse.json());
|
|
314
373
|
if (!body.success || !body.data?.id || !body.data.task_id) {
|
|
315
374
|
throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${Number(body.error?.code) || "invalid response"}`);
|
|
@@ -317,20 +376,81 @@ async function uploadLocalSpk(options, sid) {
|
|
|
317
376
|
return { id: body.data.id, taskId: body.data.task_id };
|
|
318
377
|
}
|
|
319
378
|
export async function installPackageFromLocalSpk(options) {
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
379
|
+
// The same wall budget covers upload, install/upgrade and startup verification.
|
|
380
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
381
|
+
const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
|
|
382
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
383
|
+
const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
|
|
384
|
+
let phase = 'checking package state';
|
|
385
|
+
const remaining = (limit) => {
|
|
386
|
+
if (Date.now() >= deadline)
|
|
387
|
+
throw installBudgetExpired(options);
|
|
388
|
+
return Math.min(limit, deadline - Date.now());
|
|
389
|
+
};
|
|
390
|
+
const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
|
|
391
|
+
try {
|
|
392
|
+
const sid = extractSid(options.cookieHeader);
|
|
393
|
+
let before;
|
|
394
|
+
while (!before) {
|
|
395
|
+
try {
|
|
396
|
+
before = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!isHostTimeout(error))
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
if (!before) {
|
|
403
|
+
progress(`${options.packageId}: waiting for DSM package state…`);
|
|
404
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining(requestMs))));
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (options.containerImages?.length) {
|
|
408
|
+
phase = 'pulling container images';
|
|
409
|
+
try {
|
|
410
|
+
await pullContainerImages({ ...options, images: options.containerImages,
|
|
411
|
+
deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, onProgress: progress });
|
|
412
|
+
savePullFailure(options.dsmOrigin, options.packageId);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
savePullFailure(options.dsmOrigin, options.packageId, error instanceof Error ? error.message : 'Unknown pull error');
|
|
416
|
+
throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
phase = 'uploading';
|
|
420
|
+
progress(`${options.packageId}: uploading…`);
|
|
421
|
+
const uploaded = await uploadLocalSpk(options, sid, remaining(operationMs));
|
|
422
|
+
const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: before.installed, extraValues: options.extraValues }));
|
|
423
|
+
phase = 'installing/starting';
|
|
424
|
+
progress(`${options.packageId}: ${phase}…`);
|
|
425
|
+
let timedOut = false;
|
|
426
|
+
try {
|
|
427
|
+
const response = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
428
|
+
api: 'SYNO.Entry.Request', version: '1', method: 'request', stop_when_error: 'true',
|
|
429
|
+
mode: jsonApiParam('sequential'), compound, _sid: sid,
|
|
430
|
+
}, { allowFailure: true, timeoutMs: remaining(operationMs) });
|
|
431
|
+
assertDsmCompoundResponse(response);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (!isHostTimeout(error))
|
|
435
|
+
throw error;
|
|
436
|
+
timedOut = true;
|
|
437
|
+
progress(`${options.packageId}: install/upgrade request timed out; DSM may still be installing/starting; checking state…`);
|
|
438
|
+
}
|
|
439
|
+
await waitForInstalledVersion(budget => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, Math.min(requestMs, budget)), { packageId: options.packageId, expectedVersion: options.version,
|
|
440
|
+
deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, requireRunning: true,
|
|
441
|
+
// A lost same-version reinstall reply must not accept the old running instance.
|
|
442
|
+
requireTransition: timedOut && before.installed && before.version === options.version,
|
|
443
|
+
onProgress: progress });
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (isHostTimeout(error)) {
|
|
447
|
+
if (Date.now() >= deadline)
|
|
448
|
+
throw installBudgetExpired(options);
|
|
449
|
+
throw new CtlError('HOST_TIMEOUT', 'SPK upload request timed out; no install request was sent', 'Check the NAS connection and DSM_OPERATION_TIMEOUT_SECONDS before retrying');
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
clearInterval(heartbeat);
|
|
455
|
+
}
|
|
336
456
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Use inspected effective references: these include the lock's digest and mirror. */
|
|
2
|
+
export declare function containerImageReferences(report: {
|
|
3
|
+
compose?: {
|
|
4
|
+
images: unknown[];
|
|
5
|
+
};
|
|
6
|
+
}): string[];
|
|
7
|
+
interface PullOptions {
|
|
8
|
+
dsmOrigin: string;
|
|
9
|
+
cookieHeader: string;
|
|
10
|
+
images: string[];
|
|
11
|
+
deadlineMs?: number;
|
|
12
|
+
stallMs?: number;
|
|
13
|
+
pollMs?: number;
|
|
14
|
+
onProgress?: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
/** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
|
|
17
|
+
export declare function pullContainerImages(options: PullOptions): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CtlError } from '../ctl/errors.js';
|
|
2
|
+
import { parseImageReference } from '../images/pin.js';
|
|
3
|
+
import { registryBaseUrl } from '../compose/registry-mirrors.js';
|
|
4
|
+
import { terminalSafe } from '../terminal-text.js';
|
|
5
|
+
import { maskDiagnostic } from './package-diagnostics.js';
|
|
6
|
+
import { dsmFetch } from './dsm-http.js';
|
|
7
|
+
import { dsmRequestTimeoutMs, dsmInstallTimeoutMs, dsmPollIntervalMs, dsmPullStallTimeoutMs, isHostTimeout } from '../nas/timeouts.js';
|
|
8
|
+
/** Use inspected effective references: these include the lock's digest and mirror. */
|
|
9
|
+
export function containerImageReferences(report) {
|
|
10
|
+
return [...new Set((report.compose?.images ?? []).map(value => {
|
|
11
|
+
const reference = value && typeof value === 'object' ? value.reference : undefined;
|
|
12
|
+
if (typeof reference !== 'string' || !parseImageReference(reference))
|
|
13
|
+
throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference in inspected SPK', 'Rebuild and inspect the SPK');
|
|
14
|
+
return reference;
|
|
15
|
+
}))];
|
|
16
|
+
}
|
|
17
|
+
const hint = 'Configure a reachable registry mirror, trust the inspecting CA in Container Manager, or use images pin on a trusted host and preload the pinned image on the NAS. Check Container Manager before retrying; pull tasks may still be running.';
|
|
18
|
+
const causePattern = /error|fail|denied|forbidden|unauthorized|x509|certificate|tls|timeout|timed out|refused|no such host|not found|too many requests|429|403|unexpected.*status/i;
|
|
19
|
+
/** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
|
|
20
|
+
export async function pullContainerImages(options) {
|
|
21
|
+
const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
|
|
22
|
+
const stallMs = options.stallMs ?? dsmPullStallTimeoutMs();
|
|
23
|
+
const pollMs = options.pollMs ?? dsmPollIntervalMs();
|
|
24
|
+
const progress = options.onProgress ?? (line => process.stderr.write(`${terminalSafe(line)}\n`));
|
|
25
|
+
for (const image of new Set(options.images)) {
|
|
26
|
+
const parsed = parseImageReference(image);
|
|
27
|
+
if (!parsed)
|
|
28
|
+
throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference', hint);
|
|
29
|
+
// Do not apply build-host mirror overrides here: the inspected reference is authoritative.
|
|
30
|
+
const requested = registryBaseUrl(parsed.registry, {});
|
|
31
|
+
const context = `Image ${image} (requested registry ${requested}; actual endpoint appears in the daemon detail when available)`;
|
|
32
|
+
const fail = (code, detail) => new CtlError(code, maskDiagnostic(`${context}: ${detail}`), hint);
|
|
33
|
+
let lastAdvance = Date.now(), downloaded = -1;
|
|
34
|
+
const remaining = () => {
|
|
35
|
+
if (Date.now() >= deadline)
|
|
36
|
+
throw fail('CONTAINER_PULL_TIMEOUT', 'overall install/pull budget exhausted; NAS pull outcome may still be pending');
|
|
37
|
+
if (Date.now() - lastAdvance >= stallMs)
|
|
38
|
+
throw fail('CONTAINER_PULL_STALLED', `no download progress for ${stallMs / 1000} seconds; NAS pull may still be running`);
|
|
39
|
+
return Math.max(1, Math.min(dsmRequestTimeoutMs(), deadline - Date.now(), stallMs - (Date.now() - lastAdvance)));
|
|
40
|
+
};
|
|
41
|
+
const call = async (method, params) => {
|
|
42
|
+
const response = await dsmFetch(`${options.dsmOrigin}/webapi/entry.cgi`, {
|
|
43
|
+
method: 'POST', headers: { cookie: options.cookieHeader, 'content-type': 'application/x-www-form-urlencoded' },
|
|
44
|
+
body: new URLSearchParams({ api: 'SYNO.Docker.Image', version: '1', method, ...params }),
|
|
45
|
+
}, remaining());
|
|
46
|
+
const body = await response.json();
|
|
47
|
+
if (!response.ok || body?.success !== true) {
|
|
48
|
+
const error = body?.error;
|
|
49
|
+
throw fail('CONTAINER_PULL_FAILED', `Container Manager ${method}: HTTP ${response.status}, DSM ${error?.code ?? 'unknown'} ${typeof error?.description === 'string' ? error.description : ''}; cause unknown if not supplied by DSM`);
|
|
50
|
+
}
|
|
51
|
+
return body.data;
|
|
52
|
+
};
|
|
53
|
+
progress(`${context}: starting NAS image pull…`);
|
|
54
|
+
// Even while a NAS request is pending, keep stderr alive.
|
|
55
|
+
const heartbeat = setInterval(() => progress(`${context}: pulling… (${Math.max(0, downloaded)} bytes)`), pollMs);
|
|
56
|
+
try {
|
|
57
|
+
if (parsed.digest) {
|
|
58
|
+
const inventory = await call('list', { offset: '0', limit: '-1' });
|
|
59
|
+
const cached = Array.isArray(inventory?.images) && inventory.images.some((entry) => {
|
|
60
|
+
if (typeof entry?.repository !== 'string' || typeof entry.digest !== 'string')
|
|
61
|
+
return false;
|
|
62
|
+
const local = parseImageReference(`${entry.repository}@${entry.digest}`);
|
|
63
|
+
return local?.registry === parsed.registry && local.repository === parsed.repository && local.digest === parsed.digest;
|
|
64
|
+
});
|
|
65
|
+
if (cached) {
|
|
66
|
+
progress(`${context}: pinned digest already present on NAS; no registry pull needed`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let task;
|
|
71
|
+
try {
|
|
72
|
+
task = await call('pull_start', { repository: `${parsed.registry}/${parsed.repository}`, tag: parsed.digest ?? parsed.tag });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isHostTimeout(error))
|
|
76
|
+
throw fail('CONTAINER_PULL_FAILED', 'pull_start response timed out; outcome unknown, request not replayed and package install not sent');
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (typeof task?.task_id !== 'string' || !task.task_id)
|
|
80
|
+
throw fail('CONTAINER_PULL_FAILED', 'invalid pull_start response: task_id missing');
|
|
81
|
+
lastAdvance = Date.now();
|
|
82
|
+
while (true) {
|
|
83
|
+
remaining();
|
|
84
|
+
let state;
|
|
85
|
+
try {
|
|
86
|
+
state = await call('pull_status', { task_id: task.task_id });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!isHostTimeout(error))
|
|
90
|
+
throw error;
|
|
91
|
+
progress(`${context}: status request timed out; checking existing pull task…`);
|
|
92
|
+
}
|
|
93
|
+
if (state !== undefined) {
|
|
94
|
+
if (typeof state?.finished !== 'boolean')
|
|
95
|
+
throw fail('CONTAINER_PULL_FAILED', 'invalid pull_status response: finished missing');
|
|
96
|
+
const detail = typeof state.description === 'string' ? state.description : '';
|
|
97
|
+
if (state.error || state.downloaded < 0 || causePattern.test(detail))
|
|
98
|
+
throw fail('CONTAINER_PULL_FAILED', detail || 'daemon reported a pull error; cause unknown');
|
|
99
|
+
if (typeof state.downloaded === 'number' && Number.isFinite(state.downloaded) && state.downloaded > downloaded) {
|
|
100
|
+
downloaded = state.downloaded;
|
|
101
|
+
lastAdvance = Date.now();
|
|
102
|
+
}
|
|
103
|
+
progress(maskDiagnostic(`${context}: ${state.finished ? 'pull complete' : 'pulling'} (${Math.max(0, downloaded)} bytes)${detail ? ` — ${detail}` : ''}`));
|
|
104
|
+
remaining();
|
|
105
|
+
if (state.finished)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining())));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof CtlError && error.code.startsWith('CONTAINER_PULL_'))
|
|
113
|
+
throw error;
|
|
114
|
+
throw fail('CONTAINER_PULL_FAILED', `NAS image pull failed: ${error instanceof Error ? error.message : 'unknown transport error'}`);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
clearInterval(heartbeat);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { sessionForCredentials } from "../nas/credentials.js";
|
|
2
2
|
import { CtlError } from '../ctl/errors.js';
|
|
3
|
+
import { inspectAnySpk } from '../spk/inspect.js';
|
|
4
|
+
import { containerImageReferences } from './container-pull.js';
|
|
3
5
|
import { listShareNames, lookupSharePath, } from "./share-lookup.js";
|
|
4
6
|
import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
5
7
|
import { fetchPortInfo } from "./port-info-lookup.js";
|
|
@@ -122,6 +124,7 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
122
124
|
version: input.version,
|
|
123
125
|
profileName,
|
|
124
126
|
extraValues: input.extraValues,
|
|
127
|
+
containerImages: containerImageReferences(await inspectAnySpk(input.spkPath)),
|
|
125
128
|
});
|
|
126
129
|
},
|
|
127
130
|
async getExecutorStatus(packageIdentity) {
|
|
@@ -58,7 +58,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
58
58
|
const root = requireRecord(raw, "response");
|
|
59
59
|
if (root.success !== true) {
|
|
60
60
|
const errorCode = numericErrorCode(root.error);
|
|
61
|
-
throw new DsmCompoundError("DSM_REQUEST_FAILED",
|
|
61
|
+
throw new DsmCompoundError("DSM_REQUEST_FAILED", `DSM SYNO.Entry.Request compound request failed${errorSummary(root.error)}`, {
|
|
62
62
|
dsmErrorCode: errorCode,
|
|
63
63
|
detail: root.error ?? root,
|
|
64
64
|
});
|
|
@@ -68,7 +68,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
68
68
|
const dependencyConflictIndex = parsed.results.findIndex((result) => result.errorCode === 4552);
|
|
69
69
|
if (dependencyConflictIndex >= 0) {
|
|
70
70
|
const result = parsed.results[dependencyConflictIndex];
|
|
71
|
-
throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running`, {
|
|
71
|
+
throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running (4552)`, {
|
|
72
72
|
api: result.api,
|
|
73
73
|
method: result.method,
|
|
74
74
|
errorCode: result.errorCode,
|
|
@@ -80,7 +80,7 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
80
80
|
if (parsed.hasFail || firstFailureIndex >= 0) {
|
|
81
81
|
const result = firstFailureIndex >= 0 ? parsed.results[firstFailureIndex] : undefined;
|
|
82
82
|
throw new DsmCompoundError("DSM_COMPOUND_FAILED", result
|
|
83
|
-
? `DSM compound step ${result.api}/${result.method} failed`
|
|
83
|
+
? `DSM compound step ${result.api}/${result.method} failed${errorSummary(failureDetail(resultRecords[firstFailureIndex]))}`
|
|
84
84
|
: "DSM compound request reported a failed step", {
|
|
85
85
|
api: result?.api,
|
|
86
86
|
method: result?.method,
|
|
@@ -90,6 +90,13 @@ export function assertDsmCompoundResponse(raw) {
|
|
|
90
90
|
}
|
|
91
91
|
return parsed;
|
|
92
92
|
}
|
|
93
|
+
function errorSummary(error) {
|
|
94
|
+
if (!isRecord(error))
|
|
95
|
+
return '';
|
|
96
|
+
const code = numericErrorCode(error);
|
|
97
|
+
const description = typeof error.description === 'string' ? error.description : '';
|
|
98
|
+
return `${code === undefined ? '' : ` (${code})`}${description ? `: ${description}` : ''}`;
|
|
99
|
+
}
|
|
93
100
|
function failureDetail(rawResult) {
|
|
94
101
|
const result = isRecord(rawResult) ? rawResult : undefined;
|
|
95
102
|
return result?.error ?? rawResult;
|
|
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { CtlError } from "../ctl/errors.js";
|
|
6
6
|
import { inspectAnySpk } from "../spk/inspect.js";
|
|
7
|
+
import { containerImageReferences } from './container-pull.js';
|
|
7
8
|
import { connectNas, requireAdministrator, } from "./connection.js";
|
|
8
9
|
import { fetchPackageSource } from "../package-source.js";
|
|
9
10
|
import { fetchInstalledPackageState, installPackageFromLocalSpk, uninstallPackageFromDsm, } from "./catalog-install.js";
|
|
@@ -142,6 +143,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
142
143
|
await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
|
|
143
144
|
// Download, verify and inspect the entire plan before the first NAS write.
|
|
144
145
|
const files = new Map();
|
|
146
|
+
const images = new Map();
|
|
145
147
|
for (const entry of plan) {
|
|
146
148
|
let file = localFile;
|
|
147
149
|
if (!local || entry.package !== root.package) {
|
|
@@ -159,6 +161,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
159
161
|
checked.version !== entry.version)
|
|
160
162
|
throw fail("PACKAGE_IDENTITY_MISMATCH", "Downloaded SPK identity differs from the selected release");
|
|
161
163
|
files.set(entry.package, file);
|
|
164
|
+
images.set(entry.package, containerImageReferences(checked));
|
|
162
165
|
}
|
|
163
166
|
for (const entry of plan)
|
|
164
167
|
await installPackageFromLocalSpk({
|
|
@@ -167,6 +170,7 @@ export async function installPackage(spec, options = {}, connection) {
|
|
|
167
170
|
packageId: entry.package,
|
|
168
171
|
version: entry.version,
|
|
169
172
|
spkPath: files.get(entry.package),
|
|
173
|
+
containerImages: images.get(entry.package),
|
|
170
174
|
extraValues: entry.package === root.package ? options.param : undefined,
|
|
171
175
|
});
|
|
172
176
|
return {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { InstalledPackageState } from './catalog-install.js';
|
|
2
|
+
/** Diagnostic text is untrusted and can contain daemon URLs or echoed credentials. */
|
|
3
|
+
export declare function maskDiagnostic(text: string): string;
|
|
4
|
+
export declare function savePullFailure(origin: string, packageId: string, message?: string): void;
|
|
5
|
+
export declare function packageDiagnostics(packageId: string, state: InstalledPackageState | undefined, origin: string): Record<string, unknown>;
|