@synopackageland/cli 0.17.3 → 0.17.5
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 +23 -0
- package/README.md +37 -1
- package/dist/build-spk/compose-diagnosis.d.ts +4 -0
- package/dist/build-spk/compose-diagnosis.js +12 -0
- package/dist/build-spk/compose-retained-data.d.ts +11 -0
- package/dist/build-spk/compose-retained-data.js +34 -0
- package/dist/build-spk/delete-app-data.d.ts +1 -1
- package/dist/build-spk/delete-app-data.js +1 -1
- package/dist/build-spk/uninstall-wizard.d.ts +2 -0
- package/dist/build-spk/uninstall-wizard.js +2 -1
- package/dist/build-spk/wizard-messages.d.ts +154 -0
- package/dist/build-spk/wizard-messages.js +154 -0
- package/dist/ctl/cli.js +10 -6
- package/dist/ctl/flows.js +1 -1
- package/dist/ctl/service.d.ts +4 -1
- package/dist/ctl/service.js +20 -6
- package/dist/ctl/transfers.d.ts +1 -1
- package/dist/ctl/transfers.js +5 -2
- package/dist/ctl/transport.d.ts +2 -1
- package/dist/ctl/transport.js +7 -3
- package/dist/runtime/compose-data.d.ts +10 -0
- package/dist/runtime/compose-data.js +20 -0
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/executor-policy.d.ts +25 -0
- package/dist/runtime/executor-policy.js +10 -1
- package/dist/runtime/runtime-budgets.d.ts +24 -0
- package/dist/runtime/runtime-budgets.js +13 -0
- package/dist/standalone/background.d.ts +3 -3
- package/dist/standalone/background.js +12 -11
- package/dist/standalone/compose-errors.js +10 -0
- package/dist/standalone/compose.d.ts +5 -2
- package/dist/standalone/compose.js +19 -2
- package/dist/standalone/daemon.d.ts +4 -0
- package/dist/standalone/daemon.js +36 -23
- package/dist/standalone/dsm-window.js +2 -1
- package/dist/standalone/identity-gateway.js +9 -1
- package/dist/standalone/inspect-spk.js +87 -15
- package/dist/standalone/lifecycle.js +24 -10
- package/dist/standalone/manifest.d.ts +1 -0
- package/dist/standalone/manifest.js +8 -5
- package/dist/standalone/native-gateway.d.ts +17 -0
- package/dist/standalone/native-gateway.js +115 -0
- package/dist/standalone/resource.d.ts +3 -0
- package/dist/standalone/resource.js +3 -1
- package/dist/standalone/spk.js +14 -3
- package/dist/standalone/uninstall-hook.js +21 -8
- package/dist/standalone/uninstall.d.ts +1 -1
- package/dist/standalone/uninstall.js +2 -2
- package/package.json +2 -2
- package/skills/reference/compose.md +53 -0
- package/skills/reference/create.md +1 -1
- package/skills/reference/lifecycle.md +25 -0
package/dist/ctl/service.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { CtlError, errorData, choose } from './errors.js';
|
|
3
|
-
import { APP_COOKIE, createRequest } from './transport.js';
|
|
3
|
+
import { APP_COOKIE, CTL_TIMEOUT_MS, createRequest } from './transport.js';
|
|
4
4
|
import { DESCRIPTION_METHOD, validateDescriptor, ajv } from './descriptor.js';
|
|
5
5
|
import { runFlow } from './flows.js';
|
|
6
6
|
import { SensitiveOutput } from './redaction.js';
|
|
@@ -14,11 +14,13 @@ export class CtlClient {
|
|
|
14
14
|
store;
|
|
15
15
|
host;
|
|
16
16
|
request;
|
|
17
|
-
constructor(store, host, request) {
|
|
17
|
+
constructor(store, host, request, timeoutMs = CTL_TIMEOUT_MS) {
|
|
18
18
|
this.store = store;
|
|
19
19
|
this.host = host;
|
|
20
|
-
this.request = request ?? createRequest(host.origin,
|
|
20
|
+
this.request = request ?? createRequest(host.origin, () => this.remaining(timeoutMs), host.ca ? readFileSync(host.ca, 'utf8') : undefined);
|
|
21
21
|
}
|
|
22
|
+
until;
|
|
23
|
+
remaining(fallback) { return this.until === undefined ? fallback : Math.max(0, this.until - performance.now()); }
|
|
22
24
|
async valid() { try {
|
|
23
25
|
await this.inventory(await this.store.session(this.host));
|
|
24
26
|
return true;
|
|
@@ -102,11 +104,23 @@ export class CtlClient {
|
|
|
102
104
|
return { apps: results.flatMap(r => r.description ? [r.description] : []), diagnostics: results.flatMap(r => r.error ? [r.error] : []), cacheHits };
|
|
103
105
|
}
|
|
104
106
|
async execute(name, path, args, options = {}) {
|
|
107
|
+
try {
|
|
108
|
+
return await this.executeWithin(name, path, args, options);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
this.until = undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async executeWithin(name, path, args, options = {}) {
|
|
115
|
+
const started = performance.now();
|
|
116
|
+
this.until = started + (options.timeoutMs ?? CTL_TIMEOUT_MS);
|
|
105
117
|
const listing = await this.discover(options);
|
|
106
118
|
const app = selectApp(listing.apps, name);
|
|
107
119
|
const { id, operation } = selectOperation(app, path);
|
|
108
120
|
const params = operationInput(operation, args, options.stdin, `run: synopkgland ctl ${name} ${operation.cli.join(' ')} --host ${this.host.name} --help`);
|
|
109
121
|
const output = new SensitiveOutput(params, operation.paramsSchema);
|
|
122
|
+
if (options.timeoutMs === undefined && operation.flow.type === 'job')
|
|
123
|
+
this.until = started + operation.flow.timeoutMs;
|
|
110
124
|
try {
|
|
111
125
|
const plan = requestPlan(app, id, params);
|
|
112
126
|
if (operation.flow.type === 'upload')
|
|
@@ -135,9 +149,9 @@ export class CtlClient {
|
|
|
135
149
|
return response.data;
|
|
136
150
|
};
|
|
137
151
|
const ca = this.host.ca ? readFileSync(this.host.ca, 'utf8') : undefined;
|
|
138
|
-
const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: options.timeoutMs,
|
|
139
|
-
ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: options.timeoutMs }),
|
|
140
|
-
upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: options.timeoutMs, file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
|
|
152
|
+
const result = await runFlow(operation.flow, params, invoke, { wait: options.wait, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS),
|
|
153
|
+
ndjson: (method, input) => transferRequest(appSession, app.payload.transport.path, method, input, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS) }),
|
|
154
|
+
upload: (method, input, flow) => transferRequest(appSession, app.payload.transport.path, method, {}, { ca, timeoutMs: this.remaining(options.timeoutMs ?? CTL_TIMEOUT_MS), file: { path: String(input[flow.fileParam]), parent: String(input[flow.parentParam]), name: String(input[flow.nameParam]) } }) });
|
|
141
155
|
if (operation.resultSchema && !ajv.validate(operation.resultSchema, result))
|
|
142
156
|
throw new CtlError('CTL_RESPONSE_INVALID', 'Result does not match command schema.', 'Check the App version.', 5);
|
|
143
157
|
return output.clean(result, operation.resultSchema);
|
package/dist/ctl/transfers.d.ts
CHANGED
package/dist/ctl/transfers.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createReadStream, statSync } from 'node:fs';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import https from 'node:https';
|
|
4
4
|
import { CtlError } from './errors.js';
|
|
5
|
+
import { CTL_TIMEOUT_MS } from './transport.js';
|
|
5
6
|
export function localFile(file) { if (typeof file !== 'string' || !file)
|
|
6
7
|
throw new CtlError('CTL_INPUT_FILE', 'A local filename is required.', 'Use --file <path>.'); try {
|
|
7
8
|
const stat = statSync(file);
|
|
@@ -15,6 +16,8 @@ catch {
|
|
|
15
16
|
export async function transferRequest(session, path, method, params, options = {}) {
|
|
16
17
|
if (!/^\/webman\/3rdparty\/[A-Za-z][A-Za-z0-9_.+-]*\/cgi\/api\.cgi$/.test(path) || !/^([a-z][a-z0-9_]*\.)+[a-z][a-z0-9_]*$/.test(method))
|
|
17
18
|
throw new CtlError('CTL_PATH_INVALID', 'Invalid transfer binding.', 'Refresh the API description.', 4);
|
|
19
|
+
if (options.timeoutMs !== undefined && options.timeoutMs <= 0)
|
|
20
|
+
throw new CtlError('CTL_NETWORK', 'Transfer budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
18
21
|
const url = new URL(path, session.origin);
|
|
19
22
|
const file = options.file ? localFile(options.file.path) : undefined;
|
|
20
23
|
if (options.file) {
|
|
@@ -23,7 +26,7 @@ export async function transferRequest(session, path, method, params, options = {
|
|
|
23
26
|
const raw = file ? undefined : Buffer.from(JSON.stringify({ method, params }));
|
|
24
27
|
return new Promise((resolve, reject) => {
|
|
25
28
|
const headers = { 'content-type': file ? 'application/octet-stream' : 'application/json', cookie: session.cookie, 'X-SYNO-TOKEN': session.token, origin: session.origin, 'content-length': String(file?.size ?? raw.length) };
|
|
26
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout:
|
|
29
|
+
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: options.timeoutMs ?? CTL_TIMEOUT_MS, ...(options.ca ? { ca: options.ca } : {}) }, res => {
|
|
27
30
|
const expected = file ? 'application/json' : 'application/x-ndjson';
|
|
28
31
|
if (res.statusCode !== 200 || !res.headers['content-type']?.includes(expected)) {
|
|
29
32
|
res.resume();
|
|
@@ -58,7 +61,7 @@ export async function transferRequest(session, path, method, params, options = {
|
|
|
58
61
|
reject(new CtlError('CTL_TRANSFER_FAILED', 'Invalid or failed transfer response.', 'Inspect the App result; do not retry writes blindly.', 5));
|
|
59
62
|
} });
|
|
60
63
|
});
|
|
61
|
-
const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ??
|
|
64
|
+
const wallTimer = setTimeout(() => req.destroy(), options.timeoutMs ?? CTL_TIMEOUT_MS);
|
|
62
65
|
req.once('close', () => clearTimeout(wallTimer));
|
|
63
66
|
req.on('timeout', () => req.destroy());
|
|
64
67
|
req.on('error', () => reject(new CtlError('CTL_NETWORK', 'Transfer interrupted; completion may be uncertain.', 'Check App state before retrying.', 6)));
|
package/dist/ctl/transport.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const CTL_TIMEOUT_MS = 62000;
|
|
1
2
|
export declare const APP_COOKIE: unique symbol;
|
|
2
3
|
export interface DsmSession {
|
|
3
4
|
origin: string;
|
|
@@ -8,4 +9,4 @@ export interface DsmSession {
|
|
|
8
9
|
}
|
|
9
10
|
export type JsonRequest = (path: string, body: URLSearchParams | Record<string, unknown>, session?: DsmSession) => Promise<any>;
|
|
10
11
|
export declare function canonicalOrigin(input: string, tunnel?: boolean): string;
|
|
11
|
-
export declare function createRequest(origin: string, timeoutMs?: number, ca?: string): JsonRequest;
|
|
12
|
+
export declare function createRequest(origin: string, timeoutMs?: number | (() => number), ca?: string): JsonRequest;
|
package/dist/ctl/transport.js
CHANGED
|
@@ -2,6 +2,7 @@ import { strictJson } from './json.js';
|
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import https from 'node:https';
|
|
4
4
|
import { CtlError } from './errors.js';
|
|
5
|
+
export const CTL_TIMEOUT_MS = 62000;
|
|
5
6
|
export const APP_COOKIE = Symbol("ctlAppCookie");
|
|
6
7
|
export function canonicalOrigin(input, tunnel = false) {
|
|
7
8
|
let url;
|
|
@@ -15,10 +16,13 @@ export function canonicalOrigin(input, tunnel = false) {
|
|
|
15
16
|
throw new CtlError('CTL_URL_INVALID', 'Use HTTPS, or explicit --ssh-tunnel for a loopback HTTP tunnel.', 'run: synopkgland ctl host add <name> --url https://<nas>:5001');
|
|
16
17
|
return url.origin;
|
|
17
18
|
}
|
|
18
|
-
export function createRequest(origin, timeoutMs =
|
|
19
|
+
export function createRequest(origin, timeoutMs = CTL_TIMEOUT_MS, ca) {
|
|
19
20
|
return async (path, body, session) => {
|
|
20
21
|
if (!path.startsWith('/') || path.startsWith('//') || path.includes('?') || path.includes('#') || path.includes('\\') || path.includes('%') || path.split('/').some(p => p === '.' || p === '..'))
|
|
21
22
|
throw new CtlError('CTL_PATH_INVALID', 'Unsafe request path.', 'run: synopkgland ctl list --refresh');
|
|
23
|
+
const remaining = typeof timeoutMs === 'function' ? timeoutMs() : timeoutMs;
|
|
24
|
+
if (remaining <= 0)
|
|
25
|
+
throw new CtlError('CTL_NETWORK', 'Request budget exhausted before dispatch.', 'Check App state before retrying writes.', 6);
|
|
22
26
|
const url = new URL(path, origin);
|
|
23
27
|
if (url.origin !== origin || (session && session.origin !== origin))
|
|
24
28
|
throw new CtlError('CTL_ORIGIN_MISMATCH', 'Credentials cannot cross NAS origins.', 'run: synopkgland ctl host list', 4);
|
|
@@ -32,7 +36,7 @@ export function createRequest(origin, timeoutMs = 10000, ca) {
|
|
|
32
36
|
headers.cookie = session.cookie;
|
|
33
37
|
headers['X-SYNO-TOKEN'] = session.token;
|
|
34
38
|
}
|
|
35
|
-
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout:
|
|
39
|
+
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers, timeout: remaining, ...(ca ? { ca } : {}) }, res => {
|
|
36
40
|
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
37
41
|
res.resume();
|
|
38
42
|
reject(new CtlError('CTL_HTTP_ERROR', `HTTP ${res.statusCode}; redirects are never followed.`, 'Check the NAS URL and connection.', 6));
|
|
@@ -69,7 +73,7 @@ export function createRequest(origin, timeoutMs = 10000, ca) {
|
|
|
69
73
|
}
|
|
70
74
|
});
|
|
71
75
|
});
|
|
72
|
-
const wallTimer = setTimeout(() => req.destroy(),
|
|
76
|
+
const wallTimer = setTimeout(() => req.destroy(), remaining);
|
|
73
77
|
req.once('close', () => clearTimeout(wallTimer));
|
|
74
78
|
req.on('timeout', () => req.destroy());
|
|
75
79
|
req.on('error', () => reject(new CtlError('CTL_NETWORK', 'NAS request failed or timed out.', 'Check the NAS connection and trusted CA; requests are not replayed.', 6)));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Only the trusted SDK planner supplies names for retained-data reporting. */
|
|
2
|
+
export interface ComposeAppData {
|
|
3
|
+
project: string;
|
|
4
|
+
volumes: Array<{
|
|
5
|
+
key: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export declare function validComposeAppData(value: unknown, pkg: string): value is ComposeAppData;
|
|
10
|
+
export declare const COMPOSE_DELETE_CHOICE = "compose_remove_data=0\nif [ \"${SYNOPKG_PKG_STATUS:-}\" = UNINSTALL ]; then\n for flag in \"${pkgwizard_remove_additional:-}\" \"${wizard_pkgwizard_remove_additional:-}\" \"${delete_app_data:-}\" \"${wizard_delete_app_data:-}\"; do\n case \"$flag\" in true|True|TRUE|yes|1) compose_remove_data=1 ;; esac\n done\nfi\n";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function validComposeAppData(value, pkg) {
|
|
2
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3
|
+
return false;
|
|
4
|
+
const inventory = value;
|
|
5
|
+
return Object.keys(inventory).every(key => ["project", "volumes"].includes(key)) &&
|
|
6
|
+
typeof inventory.project === "string" && /^[a-z][a-z0-9-]{0,96}$/.test(inventory.project) &&
|
|
7
|
+
(inventory.project === pkg.toLowerCase() || inventory.project.endsWith("-" + pkg.toLowerCase())) &&
|
|
8
|
+
Array.isArray(inventory.volumes) && inventory.volumes.length <= 256 &&
|
|
9
|
+
new Set(inventory.volumes.map(volume => volume?.key)).size === inventory.volumes.length &&
|
|
10
|
+
inventory.volumes.every(volume => volume && Object.keys(volume).length === 2 &&
|
|
11
|
+
typeof volume.key === "string" && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(volume.key) &&
|
|
12
|
+
volume.name === inventory.project + "_" + volume.key);
|
|
13
|
+
}
|
|
14
|
+
export const COMPOSE_DELETE_CHOICE = `compose_remove_data=0
|
|
15
|
+
if [ "\${SYNOPKG_PKG_STATUS:-}" = UNINSTALL ]; then
|
|
16
|
+
for flag in "\${pkgwizard_remove_additional:-}" "\${wizard_pkgwizard_remove_additional:-}" "\${delete_app_data:-}" "\${wizard_delete_app_data:-}"; do
|
|
17
|
+
case "$flag" in true|True|TRUE|yes|1) compose_remove_data=1 ;; esac
|
|
18
|
+
done
|
|
19
|
+
fi
|
|
20
|
+
`;
|
|
@@ -31,5 +31,27 @@
|
|
|
31
31
|
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
32
32
|
},
|
|
33
33
|
"goToolchain": "go1.26.9"
|
|
34
|
+
},
|
|
35
|
+
"0.17.4": {
|
|
36
|
+
"x86_64": "68f372684997258adc61312f2c353e1a286a6b5aaa556f716b4e6ba6f33893d0",
|
|
37
|
+
"aarch64": "f6950301e201a4ebc14577a4554fa1b671f90d0f354d1e8809f4d1dafb57c723",
|
|
38
|
+
"armv7": "e77c9b6ae22ecfa85d9834af3350192e445987265a7ca6c02ca9b01d88a1b4f7",
|
|
39
|
+
"lifecycle": {
|
|
40
|
+
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
41
|
+
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
42
|
+
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
43
|
+
},
|
|
44
|
+
"goToolchain": "go1.26.9"
|
|
45
|
+
},
|
|
46
|
+
"0.17.5": {
|
|
47
|
+
"x86_64": "af37ecd6bb5ab3e954b5f5bb3db2499b3cf86f0502afdcea18e8dc0614a46e5d",
|
|
48
|
+
"aarch64": "87436df3f591e0126d88866922b032968d0dbd01a109de53ab751ef779257baf",
|
|
49
|
+
"armv7": "de354d6e582ad1fe3124c3f30eb7f3ce8af456b0fcfad8f23c10421d3a3f3d42",
|
|
50
|
+
"lifecycle": {
|
|
51
|
+
"x86_64": "f3b368c86677741b48b059699a86c935157ea797634a6116131678e8bbaf33ed",
|
|
52
|
+
"aarch64": "b6ccd1365844e6eee9db4ce5833b655f4f1e138bf34317cdbd0e2342d3342a9e",
|
|
53
|
+
"armv7": "57b6350c2812783cb536384f8b363c26d894af6589b5f19464d71d8d0a8035d5"
|
|
54
|
+
},
|
|
55
|
+
"goToolchain": "go1.26.9"
|
|
34
56
|
}
|
|
35
57
|
}
|
|
@@ -18,6 +18,12 @@ export interface DelegateCapability {
|
|
|
18
18
|
export declare function validDelegate(value: unknown): value is DelegateCapability;
|
|
19
19
|
export declare const sha256: (data: string | Buffer) => string;
|
|
20
20
|
export interface ExecutorPolicy {
|
|
21
|
+
timeouts?: {
|
|
22
|
+
start: number;
|
|
23
|
+
stop: number;
|
|
24
|
+
purge: number;
|
|
25
|
+
operation: number;
|
|
26
|
+
};
|
|
21
27
|
webapi?: WebAPIProfiles;
|
|
22
28
|
v: 1;
|
|
23
29
|
app: string;
|
|
@@ -56,9 +62,28 @@ export declare function executorPolicy(m: {
|
|
|
56
62
|
portal?: PortalDeclaration;
|
|
57
63
|
os_min_ver?: string;
|
|
58
64
|
displayname?: string;
|
|
65
|
+
daemon?: {
|
|
66
|
+
timeouts: {
|
|
67
|
+
start: number;
|
|
68
|
+
stop: number;
|
|
69
|
+
status: number;
|
|
70
|
+
uninstall: number;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
lifecycleTimeouts?: {
|
|
74
|
+
start: number;
|
|
75
|
+
stop: number;
|
|
76
|
+
status: number;
|
|
77
|
+
uninstall: number;
|
|
78
|
+
};
|
|
79
|
+
backend?: {
|
|
80
|
+
kind: string;
|
|
81
|
+
timeoutSeconds?: number;
|
|
82
|
+
};
|
|
59
83
|
uninstall?: {
|
|
60
84
|
purge: {
|
|
61
85
|
root?: string;
|
|
86
|
+
timeout?: number;
|
|
62
87
|
};
|
|
63
88
|
};
|
|
64
89
|
}, arch?: NativeArchFamily): ExecutorPolicy;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { operationBudgetSeconds } from "./runtime-budgets.js";
|
|
1
2
|
import { validWebAPIProfiles, sortedWebAPIProfiles } from './webapi-profile.js';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { MOVE_FROM_STAGING, DELEGATE_EXTENSION_OPERATIONS } from './delegate-contract.js';
|
|
@@ -57,6 +58,12 @@ export function executorPolicy(m, arch) {
|
|
|
57
58
|
operations: [...new Set(operations)].sort(), ...(portal ? { portal } : {}), ...(purge ? { purge } : {}), ...(settings?.stop ? { stop: settings.stop } : {}),
|
|
58
59
|
...(settings?.start ? { start: settings.start } : {}),
|
|
59
60
|
...(m.capabilities.webapi ? { webapi: sortedWebAPIProfiles(m.capabilities.webapi) } : {}),
|
|
61
|
+
...((m.lifecycleTimeouts || m.daemon || m.backend?.timeoutSeconds || (m.uninstall?.purge.root && m.uninstall.purge.timeout)) ? { timeouts: {
|
|
62
|
+
start: m.lifecycleTimeouts?.start ?? m.daemon?.timeouts.start ?? 60,
|
|
63
|
+
stop: m.lifecycleTimeouts?.stop ?? m.daemon?.timeouts.stop ?? 60,
|
|
64
|
+
purge: m.lifecycleTimeouts?.uninstall ?? m.daemon?.timeouts.uninstall ?? Math.min(3600, Math.max(300, m.uninstall?.purge.timeout ?? 0)),
|
|
65
|
+
operation: operationBudgetSeconds(m),
|
|
66
|
+
} } : {}),
|
|
60
67
|
root, ...(settings?.state ? { root_state: true } : {}) };
|
|
61
68
|
}
|
|
62
69
|
/** A script without settings keeps the bare digest; a binary pins the bytes built for this architecture. */
|
|
@@ -86,7 +93,7 @@ export function validExecutorPolicy(value, pkg) {
|
|
|
86
93
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
87
94
|
return false;
|
|
88
95
|
const p = value;
|
|
89
|
-
if (Object.keys(p).some(key => !['v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'start', 'root', 'root_state', 'webapi'].includes(key))
|
|
96
|
+
if (Object.keys(p).some(key => !['timeouts', 'v', 'package', 'app', 'entry', 'scope', 'destinations', 'lifetime', 'operations', 'portal', 'purge', 'stop', 'start', 'root', 'root_state', 'webapi'].includes(key))
|
|
90
97
|
|| p.v !== 1 || p.package !== pkg || p.app !== `SYNO.SDS.${pkg}.Instance`
|
|
91
98
|
|| 'entry' in p && p.entry !== 'admin-only' && p.entry !== 'app-privilege'
|
|
92
99
|
|| !Array.isArray(p.operations) || !p.operations.includes('status') || new Set(p.operations).size !== p.operations.length
|
|
@@ -97,6 +104,8 @@ export function validExecutorPolicy(value, pkg) {
|
|
|
97
104
|
|| 'root_state' in p && p.root_state !== true
|
|
98
105
|
|| Object.entries(p.root).some(([name, entry]) => !ROOT_PROGRAM_NAME.test(name) || rootEntryProblem(entry) !== undefined))
|
|
99
106
|
return false;
|
|
107
|
+
if (p.timeouts !== undefined && (!p.timeouts || typeof p.timeouts !== 'object' || Array.isArray(p.timeouts) || Object.keys(p.timeouts).sort().join(',') !== 'operation,purge,start,stop' || Object.entries(p.timeouts).some(([key, n]) => !Number.isInteger(n) || n < (key === 'operation' ? 1 : 2) || n > 3600)))
|
|
108
|
+
return false;
|
|
100
109
|
const rootOps = rootOperations(p.root);
|
|
101
110
|
if (![...ROOT_JOB_OPERATIONS, 'root.run'].every(op => p.operations.includes(op) === rootOps.includes(op)))
|
|
102
111
|
return false;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Work budgets use existing author settings; credential lifetimes stay separate. */
|
|
2
|
+
interface OperationBudgetSource {
|
|
3
|
+
backend?: {
|
|
4
|
+
kind: string;
|
|
5
|
+
timeoutSeconds?: number;
|
|
6
|
+
};
|
|
7
|
+
daemon?: {
|
|
8
|
+
timeouts?: {
|
|
9
|
+
status: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
lifecycleTimeouts?: {
|
|
13
|
+
status: number;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A CGI's declared request budget owns operation work. Binary and Compose Apps
|
|
18
|
+
* have no compiled CGI deadline to configure, so their existing status allowance
|
|
19
|
+
* can enlarge the shared operation budget above its 60-second default.
|
|
20
|
+
*/
|
|
21
|
+
export declare function operationBudgetSeconds(source: OperationBudgetSource): number;
|
|
22
|
+
/** First released SDK that decodes operation/lifecycle budget config fields. */
|
|
23
|
+
export declare function runtimeBudgetSdkSupported(sdk: string): boolean;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CGI's declared request budget owns operation work. Binary and Compose Apps
|
|
3
|
+
* have no compiled CGI deadline to configure, so their existing status allowance
|
|
4
|
+
* can enlarge the shared operation budget above its 60-second default.
|
|
5
|
+
*/
|
|
6
|
+
export function operationBudgetSeconds(source) {
|
|
7
|
+
return source.backend?.timeoutSeconds ?? Math.max(60, source.lifecycleTimeouts?.status ?? source.daemon?.timeouts?.status ?? 5);
|
|
8
|
+
}
|
|
9
|
+
/** First released SDK that decodes operation/lifecycle budget config fields. */
|
|
10
|
+
export function runtimeBudgetSdkSupported(sdk) {
|
|
11
|
+
const [major, minor, patch] = sdk.split('.').map(Number);
|
|
12
|
+
return major > 0 || minor > 17 || minor === 17 && patch >= 5;
|
|
13
|
+
}
|
|
@@ -18,7 +18,7 @@ export declare function readBackground(value: unknown, fail: (error: StandaloneE
|
|
|
18
18
|
export declare function renderBackgroundResource(): string;
|
|
19
19
|
/** The worker's arguments: none for a Go App's own worker, `--worker` for the bridge runtime (spec/bridge/v1). */
|
|
20
20
|
export declare const BRIDGE_WORKER_ARGS: readonly ["--worker"];
|
|
21
|
-
export declare function renderBackgroundUnit(pkg: string, args?: readonly string[]): string;
|
|
21
|
+
export declare function renderBackgroundUnit(pkg: string, args?: readonly string[], stopSeconds?: number): string;
|
|
22
22
|
/** Shell function that stops the worker in lifecycle hooks; call it as `stop_background_worker`. */
|
|
23
23
|
export declare function renderBackgroundWorkerStop(pkg: string): string;
|
|
24
24
|
/**
|
|
@@ -32,8 +32,8 @@ export declare function renderBackgroundWorkerStop(pkg: string): string;
|
|
|
32
32
|
* exits. Stop only such a manager, as the package user; the acquire that
|
|
33
33
|
* follows then starts a fresh one.
|
|
34
34
|
*/
|
|
35
|
-
export declare function renderUserManagerRecovery(): string;
|
|
35
|
+
export declare function renderUserManagerRecovery(startSeconds?: number): string;
|
|
36
36
|
/** Shell function that starts the worker and names the failed step; call it as `start_background_worker`. */
|
|
37
37
|
export declare function renderBackgroundWorkerStart(pkg: string): string;
|
|
38
38
|
/** Package Center's start/stop/status map straight onto the user unit. */
|
|
39
|
-
export declare function renderBackgroundControl(pkg: string): string;
|
|
39
|
+
export declare function renderBackgroundControl(pkg: string, startSeconds?: number): string;
|
|
@@ -24,7 +24,7 @@ export function renderBackgroundResource() {
|
|
|
24
24
|
}
|
|
25
25
|
/** The worker's arguments: none for a Go App's own worker, `--worker` for the bridge runtime (spec/bridge/v1). */
|
|
26
26
|
export const BRIDGE_WORKER_ARGS = ["--worker"];
|
|
27
|
-
export function renderBackgroundUnit(pkg, args = []) {
|
|
27
|
+
export function renderBackgroundUnit(pkg, args = [], stopSeconds = 30) {
|
|
28
28
|
return `[Unit]
|
|
29
29
|
Description=${pkg} background worker
|
|
30
30
|
|
|
@@ -33,7 +33,7 @@ Type=simple
|
|
|
33
33
|
ExecStart=${[`/var/packages/${pkg}/target/${BACKGROUND_WORKER_PAYLOAD}`, ...args].join(" ")}
|
|
34
34
|
UMask=0077
|
|
35
35
|
KillMode=control-group
|
|
36
|
-
TimeoutStopSec
|
|
36
|
+
TimeoutStopSec=${stopSeconds}
|
|
37
37
|
Restart=no
|
|
38
38
|
`;
|
|
39
39
|
}
|
|
@@ -66,13 +66,15 @@ export function renderBackgroundWorkerStop(pkg) {
|
|
|
66
66
|
* exits. Stop only such a manager, as the package user; the acquire that
|
|
67
67
|
* follows then starts a fresh one.
|
|
68
68
|
*/
|
|
69
|
-
export function renderUserManagerRecovery() {
|
|
69
|
+
export function renderUserManagerRecovery(startSeconds = 30) {
|
|
70
70
|
return `recover_user_manager() {
|
|
71
71
|
uid="$(id -u)" || return 1
|
|
72
72
|
[ "$uid" != 0 ] || return 0
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
manager_now() { awk '{print int($1)}' /proc/uptime; }
|
|
74
|
+
manager_until=$(( $(manager_now) + ${startSeconds} ))
|
|
75
|
+
while [ "$(/usr/bin/loginctl show-user "$uid" -p State 2>/dev/null)" = State=closing ]; do
|
|
76
|
+
[ "$(manager_now)" -lt "$manager_until" ] || { echo "package user manager still closing" >&2; return 1; }
|
|
77
|
+
sleep 0.1
|
|
76
78
|
done
|
|
77
79
|
[ -S "/run/user/$uid/systemd/private" ] && return 0
|
|
78
80
|
for exe in /proc/[0-9]*/exe; do
|
|
@@ -81,12 +83,11 @@ export function renderUserManagerRecovery() {
|
|
|
81
83
|
case " $(tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)" in *" --user "*) ;; *) continue ;; esac
|
|
82
84
|
echo "package user manager $pid has no socket in /run/user/$uid; stopping it so DSM starts a fresh one" >&2
|
|
83
85
|
kill -TERM "$pid" || return 1
|
|
84
|
-
i=0
|
|
85
86
|
# DSM's bash logs a failed kill to syslog even with 2>/dev/null, so the
|
|
86
87
|
# exit is looked up in /proc.
|
|
87
88
|
while [ -d "/proc/$pid" ]; do
|
|
88
|
-
[ "$
|
|
89
|
-
sleep 0.1
|
|
89
|
+
[ "$(manager_now)" -lt "$manager_until" ] || { echo "package user manager $pid did not exit" >&2; return 1; }
|
|
90
|
+
sleep 0.1
|
|
90
91
|
done
|
|
91
92
|
done
|
|
92
93
|
}
|
|
@@ -105,10 +106,10 @@ export function renderBackgroundWorkerStart(pkg) {
|
|
|
105
106
|
`;
|
|
106
107
|
}
|
|
107
108
|
/** Package Center's start/stop/status map straight onto the user unit. */
|
|
108
|
-
export function renderBackgroundControl(pkg) {
|
|
109
|
+
export function renderBackgroundControl(pkg, startSeconds = 30) {
|
|
109
110
|
const unit = backgroundUnitName(pkg);
|
|
110
111
|
return `#!/bin/sh
|
|
111
|
-
${renderUserManagerRecovery()}${renderBackgroundWorkerStart(pkg)}case "$1" in
|
|
112
|
+
${renderUserManagerRecovery(startSeconds)}${renderBackgroundWorkerStart(pkg)}case "$1" in
|
|
112
113
|
prestart) recover_user_manager ;;
|
|
113
114
|
prestop) exit 0 ;;
|
|
114
115
|
start) start_background_worker ;;
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/** SDK planner errors accepted at this versioned tool boundary. */
|
|
2
2
|
export const COMPOSE_ERRORS = {
|
|
3
|
+
COMPOSE_STARTUP_INVALID: {
|
|
4
|
+
code: "COMPOSE_STARTUP_INVALID",
|
|
5
|
+
description: "The container startup budget is invalid.",
|
|
6
|
+
suggestion: "Set compose.startup_timeout_seconds to an integer from 2 to 900 seconds; the default is 120. Read the same SYNOPKGLAND_STARTUP_BUDGET_SECONDS in the App bootstrap adapter.",
|
|
7
|
+
},
|
|
8
|
+
COMPOSE_STARTUP_TIMEOUT: {
|
|
9
|
+
code: "COMPOSE_STARTUP_TIMEOUT",
|
|
10
|
+
description: "Container readiness exhausted its declared startup budget.",
|
|
11
|
+
suggestion: "Inspect the safe phase code in daemon/daemon.log and upstream migration/bootstrap health. Declare a sufficient bounded startup_timeout_seconds and rebuild; keep ingress closed until ready.",
|
|
12
|
+
},
|
|
3
13
|
COMPOSE_STORAGE_INIT_INVALID: {
|
|
4
14
|
code: "COMPOSE_STORAGE_INIT_INVALID",
|
|
5
15
|
description: "The container storage initialization declaration is invalid.",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { type ComposeAppData } from "../runtime/compose-data.js";
|
|
1
2
|
import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
2
3
|
import { type Daemon } from "./daemon.js";
|
|
3
4
|
import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
4
5
|
export interface ComposePlan {
|
|
6
|
+
app_data?: ComposeAppData;
|
|
7
|
+
startup_timeout_seconds?: number;
|
|
5
8
|
ingress?: {
|
|
6
9
|
address: string;
|
|
7
10
|
subnet: string;
|
|
@@ -68,7 +71,7 @@ export interface ComposeApp {
|
|
|
68
71
|
document: Record<string, any>;
|
|
69
72
|
plan: ComposePlan;
|
|
70
73
|
}
|
|
71
|
-
export declare const COMPOSE_FIELDS: readonly ["resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
|
|
74
|
+
export declare const COMPOSE_FIELDS: readonly ["startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
|
|
72
75
|
export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
|
|
73
76
|
package?: string;
|
|
74
77
|
sdk?: string;
|
|
@@ -78,7 +81,7 @@ export declare function readComposeApp(value: unknown, root: string, arches: Nat
|
|
|
78
81
|
architecture?: NativeArchFamily;
|
|
79
82
|
imageProofs?: unknown;
|
|
80
83
|
}): ComposeApp | undefined;
|
|
81
|
-
export declare function composeDaemon(m: Pick<StandaloneManifest, "compose">): Daemon;
|
|
84
|
+
export declare function composeDaemon(m: Pick<StandaloneManifest, "compose" | "lifecycleTimeouts">): Daemon;
|
|
82
85
|
export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
|
|
83
86
|
public_ports: number[];
|
|
84
87
|
readiness?: {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validComposeAppData } from "../runtime/compose-data.js";
|
|
2
|
+
import { composeDiagnosisMessages, renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
|
|
1
3
|
import { COMPOSE_ERRORS } from "./compose-errors.js";
|
|
2
4
|
import { spawnSync } from "node:child_process";
|
|
3
5
|
import { createHash } from "node:crypto";
|
|
@@ -10,6 +12,7 @@ import { goChildEnv } from "../runtime/go-env.js";
|
|
|
10
12
|
import { sdkSource } from "../runtime/sdk-source.js";
|
|
11
13
|
import { readDaemon } from "./daemon.js";
|
|
12
14
|
export const COMPOSE_FIELDS = [
|
|
15
|
+
"startup_timeout_seconds",
|
|
13
16
|
"resource_prefix",
|
|
14
17
|
"files",
|
|
15
18
|
"env_files",
|
|
@@ -69,6 +72,10 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
69
72
|
if (value.sdk !== undefined &&
|
|
70
73
|
(typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
|
|
71
74
|
return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
|
|
75
|
+
if (value.startup_timeout_seconds !== undefined &&
|
|
76
|
+
(!Number.isInteger(value.startup_timeout_seconds) ||
|
|
77
|
+
value.startup_timeout_seconds < 2 || value.startup_timeout_seconds > 900))
|
|
78
|
+
return invalid("compose.startup_timeout_seconds must be an integer from 2 to 900 seconds (default 120).", "COMPOSE_STARTUP_INVALID");
|
|
72
79
|
const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? templateSdkVersion());
|
|
73
80
|
let targets = {};
|
|
74
81
|
if (value.helpers !== undefined) {
|
|
@@ -107,7 +114,16 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
107
114
|
}
|
|
108
115
|
if (result.status !== 0 || plan.error)
|
|
109
116
|
throw new Error(plan.error ?? "[SDK_COMPOSE_FAILED] SDK planner failed.");
|
|
117
|
+
if (value.startup_timeout_seconds !== undefined &&
|
|
118
|
+
plan.startup_timeout_seconds !== value.startup_timeout_seconds)
|
|
119
|
+
throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK does not implement the declared startup budget; use the matching SDK/CLI release.");
|
|
110
120
|
const document = parse(plan.effective);
|
|
121
|
+
const volumes = Object.entries(document?.volumes ?? {});
|
|
122
|
+
if ((!validComposeAppData(plan.app_data, context.package ?? "ComposeApp")
|
|
123
|
+
|| JSON.stringify(plan.app_data) !== JSON.stringify(plan.lock?.app_data)
|
|
124
|
+
|| plan.app_data.project !== plan.project_name || plan.app_data.volumes.length !== volumes.length
|
|
125
|
+
|| volumes.some(([key, value]) => !plan.app_data.volumes.some(v => v.key === key && v.name === value.name))))
|
|
126
|
+
throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK does not provide the complete Compose App data contract; update the SDK and rebuild.");
|
|
111
127
|
const web = value.web;
|
|
112
128
|
return {
|
|
113
129
|
declaration: { ...value },
|
|
@@ -141,7 +157,7 @@ export function composeDaemon(m) {
|
|
|
141
157
|
args: ["run"],
|
|
142
158
|
targets: m.compose.targets,
|
|
143
159
|
env: {},
|
|
144
|
-
timeouts: {
|
|
160
|
+
timeouts: { stop: 30, status: 5, uninstall: 60, ...m.lifecycleTimeouts, start: m.compose.plan.startup_timeout_seconds ?? m.lifecycleTimeouts?.start ?? 120 },
|
|
145
161
|
};
|
|
146
162
|
}
|
|
147
163
|
export function composeNetwork(m) {
|
|
@@ -187,6 +203,7 @@ export function writeComposePayload(c, root, target, gateway) {
|
|
|
187
203
|
"compose.lock.json": JSON.stringify(c.plan.lock, null, 2) + "\n",
|
|
188
204
|
"request.json": JSON.stringify({ declaration: c.declaration, sdk: c.sdk, gateway }, null, 2) + "\n",
|
|
189
205
|
"images.json": JSON.stringify(c.plan.image_proofs ?? {}, null, 2) + "\n",
|
|
206
|
+
"diagnosis-messages.json": JSON.stringify(composeDiagnosisMessages(), null, 2) + "\n",
|
|
190
207
|
}))
|
|
191
208
|
writeFileSync(join(target, name), content, { mode: 0o600 });
|
|
192
209
|
}
|
|
@@ -195,7 +212,7 @@ export function renderComposeInstall(pkg) {
|
|
|
195
212
|
return `#!/bin/sh
|
|
196
213
|
set -eu
|
|
197
214
|
"\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" preflight || exit $?
|
|
198
|
-
exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
|
|
215
|
+
${renderComposeDiagnosis("\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose", "preinst")}exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
|
|
199
216
|
`;
|
|
200
217
|
}
|
|
201
218
|
/** The same SDK entry point supplies the package-account install materializer. */
|
|
@@ -30,3 +30,7 @@ export declare function buildDaemonControl(manifest: StandaloneManifest, arch: N
|
|
|
30
30
|
export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
|
|
31
31
|
/** Copies a bounded, link-free package-account payload tree. */
|
|
32
32
|
export declare function copyPayloadTree(root: string, source: string, target: string): void;
|
|
33
|
+
/** Existing lifecycle namespace; a timeout-only declaration runs no daemon. */
|
|
34
|
+
export declare function readLifecycleTimeouts(value: unknown, fail: (e: StandaloneError) => void): Daemon['timeouts'] | undefined;
|
|
35
|
+
/** Legacy SDKs keep their supported config and timeout bounds. */
|
|
36
|
+
export declare function runtimeBudgetSource(dir: string, manifest?: StandaloneManifest): string;
|