@synopackageland/cli 0.3.1 → 0.3.3
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/dist/attestation/app-package.js +8 -1
- package/dist/attestation/platform-service.d.ts +70 -0
- package/dist/attestation/platform-service.js +250 -0
- package/dist/bin.js +0 -0
- package/dist/build-spk/generator.js +200 -10
- package/dist/build-spk/resource-worker.d.ts +14 -0
- package/dist/build-spk/resource-worker.js +140 -0
- package/dist/cli.js +69 -10
- package/dist/compose/analyze.d.ts +42 -1
- package/dist/compose/analyze.js +482 -18
- package/dist/contract/load.d.ts +1 -0
- package/dist/contract/load.js +1 -1
- package/dist/contract/posix-all-shares.d.ts +16 -0
- package/dist/contract/posix-all-shares.js +63 -0
- package/dist/contract/schema-validate.js +26 -1
- package/dist/contract/validate.js +57 -0
- package/dist/contract/web-launch.d.ts +6 -0
- package/dist/contract/web-launch.js +25 -0
- package/dist/deploy/deploy.d.ts +1 -0
- package/dist/deploy/deploy.js +94 -2
- package/dist/deploy/homes-values.d.ts +19 -0
- package/dist/deploy/homes-values.js +29 -0
- package/dist/deploy/param-values.d.ts +21 -0
- package/dist/deploy/param-values.js +51 -0
- package/dist/deploy/spk-version.d.ts +13 -0
- package/dist/deploy/spk-version.js +80 -27
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +594 -0
- package/dist/error-collector.d.ts +2 -0
- package/dist/error-collector.js +46 -0
- package/dist/error-registry.d.ts +7 -0
- package/dist/error-registry.js +837 -0
- package/dist/host/broker-upgrade-preflight.d.ts +61 -0
- package/dist/host/broker-upgrade-preflight.js +156 -0
- package/dist/host/catalog-install.d.ts +20 -0
- package/dist/host/catalog-install.js +143 -17
- package/dist/host/credentials.js +35 -5
- package/dist/host/dsm-client.d.ts +12 -0
- package/dist/host/dsm-client.js +69 -1
- package/dist/host/dsm-compound.d.ts +37 -0
- package/dist/host/dsm-compound.js +120 -0
- package/dist/host/fake-nas.d.ts +1 -0
- package/dist/host/fake-nas.js +3 -0
- package/dist/host/types.d.ts +6 -0
- package/dist/publish/curated-release.js +8 -4
- package/dist/publish/platform-service-release.d.ts +60 -0
- package/dist/publish/platform-service-release.js +333 -0
- package/dist/publish/preflight.js +24 -2
- package/dist/release-bin.js +5 -1
- package/dist/templates/hello-files/compose.yaml +2 -0
- package/dist/templates/hello-web/compose.yaml +14 -3
- package/dist/templates/hello-web/synology-app.yaml +1 -1
- package/dist/templates/hello-web-page/compose.yaml +14 -3
- package/dist/templates/hello-web-page/synology-app.yaml +1 -1
- package/dist/test-host.d.ts +2 -0
- package/dist/test-host.js +15 -2
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +14 -3
- package/package.json +2 -2
- package/schema/doctor.schema.json +38 -0
- package/schema/synology-app.schema.json +1 -1
- package/skills/reference/create.md +19 -0
- package/skills/reference/diagnose.md +35 -2
- package/skills/reference/files.md +10 -0
- package/templates/hello-files/compose.yaml +2 -0
- package/templates/hello-web/compose.yaml +14 -3
- package/templates/hello-web/synology-app.yaml +1 -1
- package/templates/hello-web-page/compose.yaml +14 -3
- package/templates/hello-web-page/synology-app.yaml +1 -1
package/dist/compose/analyze.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
4
|
import { composeSpan, makeError } from "../errors.js";
|
|
5
|
+
import { collectInstallParameters, loadContract } from "../contract/load.js";
|
|
5
6
|
import { undeclaredPrivilegeFindings } from "./privilege.js";
|
|
7
|
+
import { scanComposePrivilege } from "./privilege.js";
|
|
6
8
|
const DOCKER_SOCKET_PATHS = [
|
|
7
9
|
"/var/run/docker.sock",
|
|
8
10
|
"/var/run/docker.sock/",
|
|
9
11
|
"/var/run/containers/container-manager.sock",
|
|
10
12
|
"/var/run/containers/container-manager.sock/",
|
|
11
13
|
];
|
|
14
|
+
const composeProjectRoots = new WeakMap();
|
|
15
|
+
const composeSources = new WeakMap();
|
|
16
|
+
const composeDocuments = new WeakMap();
|
|
17
|
+
const composeContracts = new WeakMap();
|
|
18
|
+
const HOST_BIND_ERROR_CODES = new Set([
|
|
19
|
+
"COMPOSE_BIND_SOURCE_FORBIDDEN",
|
|
20
|
+
"COMPOSE_BIND_SOURCE_UNDECLARED",
|
|
21
|
+
"COMPOSE_BIND_SUFFIX_INVALID",
|
|
22
|
+
"COMPOSE_HOST_BIND_FORBIDDEN",
|
|
23
|
+
"COMPOSE_NAMED_VOLUME_UNDECLARED",
|
|
24
|
+
]);
|
|
12
25
|
export function loadCompose(composePath) {
|
|
13
26
|
if (!existsSync(composePath)) {
|
|
14
27
|
return null;
|
|
@@ -16,7 +29,21 @@ export function loadCompose(composePath) {
|
|
|
16
29
|
const source = readFileSync(composePath, "utf8");
|
|
17
30
|
const doc = parseDocument(source);
|
|
18
31
|
const compose = (doc.toJSON() ?? {});
|
|
19
|
-
|
|
32
|
+
if (compose && typeof compose === "object") {
|
|
33
|
+
composeProjectRoots.set(compose, dirname(resolve(composePath)));
|
|
34
|
+
composeSources.set(compose, source);
|
|
35
|
+
composeDocuments.set(compose, doc);
|
|
36
|
+
const contractPath = join(dirname(resolve(composePath)), "synology-app.yaml");
|
|
37
|
+
if (existsSync(contractPath)) {
|
|
38
|
+
try {
|
|
39
|
+
composeContracts.set(compose, loadContract(contractPath).contract);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Contract validation reports malformed input; Compose analysis still returns its own findings.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { compose, source, document: doc };
|
|
20
47
|
}
|
|
21
48
|
export function listServiceNames(compose) {
|
|
22
49
|
if (!compose.services || typeof compose.services !== "object") {
|
|
@@ -77,6 +104,322 @@ export function collectPublishedTcpPorts(compose) {
|
|
|
77
104
|
}
|
|
78
105
|
return result;
|
|
79
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
|
|
109
|
+
* Zero-config slice rejects:
|
|
110
|
+
* - any install parameter
|
|
111
|
+
* - any ${...} interpolation anywhere in compose document
|
|
112
|
+
* - compose secrets or configs
|
|
113
|
+
* - build: stanzas
|
|
114
|
+
* - privileged / host network / device / root-like capabilities
|
|
115
|
+
* - any App without a unique literal published adminport status probe
|
|
116
|
+
*/
|
|
117
|
+
export function resourceWorkerEligibility(contract, compose) {
|
|
118
|
+
const reasons = [];
|
|
119
|
+
if (contract.install && Object.keys(contract.install).length > 0) {
|
|
120
|
+
reasons.push("HAS_INSTALL_PARAMETERS");
|
|
121
|
+
}
|
|
122
|
+
if (hasInterpolation(compose)) {
|
|
123
|
+
reasons.push("HAS_COMPOSE_INTERPOLATION");
|
|
124
|
+
}
|
|
125
|
+
if ((compose.secrets && Object.keys(compose.secrets).length > 0) ||
|
|
126
|
+
(compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0)) {
|
|
127
|
+
reasons.push("HAS_COMPOSE_SECRETS_OR_CONFIGS");
|
|
128
|
+
}
|
|
129
|
+
if (hasBuildStanza(compose)) {
|
|
130
|
+
reasons.push("HAS_BUILD_STANZA");
|
|
131
|
+
}
|
|
132
|
+
if (scanComposePrivilege(compose).length > 0) {
|
|
133
|
+
reasons.push("HAS_PRIVILEGED_CAPABILITY");
|
|
134
|
+
}
|
|
135
|
+
let resolvedAdminport;
|
|
136
|
+
if (contract.adminport === undefined) {
|
|
137
|
+
reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const rawPort = Number(contract.adminport);
|
|
141
|
+
if (!Number.isInteger(rawPort) || rawPort <= 0 || rawPort > 65535) {
|
|
142
|
+
reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const published = collectPublishedTcpPorts(compose);
|
|
146
|
+
const services = published.get(rawPort) ?? [];
|
|
147
|
+
if (services.length !== 1) {
|
|
148
|
+
reasons.push("WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT");
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
resolvedAdminport = rawPort;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
eligible: reasons.length === 0,
|
|
157
|
+
reasons,
|
|
158
|
+
adminport: resolvedAdminport,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function hasInterpolation(value) {
|
|
162
|
+
if (typeof value === "string") {
|
|
163
|
+
return /\$\{([^}]+)\}/.test(value);
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
return value.some(hasInterpolation);
|
|
167
|
+
}
|
|
168
|
+
if (value && typeof value === "object") {
|
|
169
|
+
return Object.values(value).some(hasInterpolation);
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
function hasBuildStanza(compose) {
|
|
174
|
+
if (!compose.services) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return Object.values(compose.services).some((service) => "build" in service && service.build !== undefined);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Chooses the local route D0 can safely emit. The first dynamic slice is
|
|
181
|
+
* deliberately small: only string/boolean wizard answers interpolated as
|
|
182
|
+
* ordinary Compose values. Secrets/configs remain rejected because the live
|
|
183
|
+
* DSM worker does not load docker-compose.override.yml, and no secret may be
|
|
184
|
+
* placed in the project .env.
|
|
185
|
+
*/
|
|
186
|
+
export function classifyComposeRoute(contract, parsed, composeFile = "compose.yaml") {
|
|
187
|
+
const eligibility = resourceWorkerEligibility(contract, parsed.compose);
|
|
188
|
+
const rejectErrors = contract.adminport !== undefined
|
|
189
|
+
? collectRouteRejectErrors(contract, parsed, composeFile)
|
|
190
|
+
: [];
|
|
191
|
+
if (rejectErrors.length > 0) {
|
|
192
|
+
return { route: "reject", eligibility, errors: rejectErrors };
|
|
193
|
+
}
|
|
194
|
+
const dynamicError = contract.adminport !== undefined
|
|
195
|
+
? collectDynamicRouteError(contract, parsed, composeFile)
|
|
196
|
+
: undefined;
|
|
197
|
+
if (dynamicError) {
|
|
198
|
+
return {
|
|
199
|
+
route: "reject",
|
|
200
|
+
eligibility,
|
|
201
|
+
errors: [dynamicError],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (eligibility.eligible) {
|
|
205
|
+
return { route: "static-zero-config", eligibility, errors: [] };
|
|
206
|
+
}
|
|
207
|
+
// Keep the route decision in one place. build-spk must not independently
|
|
208
|
+
// reinterpret an ineligible Compose as D1: only a route with a resolved
|
|
209
|
+
// published adminport and at least one declared install parameter has a
|
|
210
|
+
// package-only materializer. Every other buildable fallback is Broker.
|
|
211
|
+
if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
|
|
212
|
+
return { route: "dynamic", eligibility, errors: [] };
|
|
213
|
+
}
|
|
214
|
+
return { route: "broker-installer", eligibility, errors: [] };
|
|
215
|
+
}
|
|
216
|
+
export function brokerInstallerRouteWarning(compose, composeFile = "compose.yaml") {
|
|
217
|
+
const path = findDynamicComposePath(compose) ?? "services";
|
|
218
|
+
return makeError("CURATED_BROKER_ROUTE_FORBIDDEN", composeSpanForPath(compose, composeFile, path), "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,無法作為 Curated production runtime。", "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。", "warning");
|
|
219
|
+
}
|
|
220
|
+
function hasImmutableServiceImages(compose) {
|
|
221
|
+
const services = compose.services;
|
|
222
|
+
if (!services || Object.keys(services).length === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return Object.values(services).every((service) => typeof service.image === "string" && /@sha256:[a-f0-9]{64}$/.test(service.image.trim()));
|
|
226
|
+
}
|
|
227
|
+
function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
228
|
+
const { compose } = parsed;
|
|
229
|
+
const errors = collectPermanentRouteErrors(contract, parsed, composeFile);
|
|
230
|
+
const installParams = collectInstallParameters(contract);
|
|
231
|
+
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
232
|
+
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
233
|
+
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
234
|
+
errors.push(...validateComposeHostBinds(compose, composeFile, {
|
|
235
|
+
sharedFolderParams,
|
|
236
|
+
homesParams,
|
|
237
|
+
installParamNames,
|
|
238
|
+
}));
|
|
239
|
+
return dedupeValidationErrors(errors);
|
|
240
|
+
}
|
|
241
|
+
function collectPermanentRouteErrors(contract, parsed, composeFile) {
|
|
242
|
+
if (contract.adminport === undefined) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const { compose } = parsed;
|
|
246
|
+
const errors = [];
|
|
247
|
+
if (compose.services) {
|
|
248
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
249
|
+
const basePath = `services.${serviceName}`;
|
|
250
|
+
if ("build" in service && service.build !== undefined) {
|
|
251
|
+
const path = `${basePath}.build`;
|
|
252
|
+
errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
|
|
253
|
+
}
|
|
254
|
+
errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const finding of scanComposePrivilege(compose)) {
|
|
258
|
+
errors.push(makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), `service「${finding.serviceName}」使用 ${finding.flag};D0 route 永久拒收高權限 capability,即使 contract 已宣告也不會放行。`, "請移除 privileged、host network/PID/IPC、devices 或 cap_add,改用 package-only static-zero-config Compose。"));
|
|
259
|
+
}
|
|
260
|
+
return errors;
|
|
261
|
+
}
|
|
262
|
+
function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
263
|
+
const { compose } = parsed;
|
|
264
|
+
const params = collectInstallParameters(contract);
|
|
265
|
+
const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
|
|
266
|
+
const supportedParamNames = new Set(params
|
|
267
|
+
.filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
268
|
+
(param.type === "string" || param.type === "boolean"))
|
|
269
|
+
.map(({ name }) => name));
|
|
270
|
+
const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
|
|
271
|
+
(param.type !== "string" && param.type !== "boolean"));
|
|
272
|
+
const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
|
|
273
|
+
? BROKER_OIDC_INTERPOLATION_VARS
|
|
274
|
+
: [];
|
|
275
|
+
const supportedInterpolationNames = new Set([
|
|
276
|
+
...supportedParamNames,
|
|
277
|
+
...PLATFORM_BIND_VARS,
|
|
278
|
+
...brokerOidcInterpolationNames,
|
|
279
|
+
]);
|
|
280
|
+
const interpolations = collectComposeInterpolations(compose);
|
|
281
|
+
const hasComposeInterpolation = hasInterpolation(compose);
|
|
282
|
+
const unsupportedInterpolation = !hasComposeInterpolation
|
|
283
|
+
? undefined
|
|
284
|
+
: interpolations.size === 0
|
|
285
|
+
? "<unsupported interpolation syntax>"
|
|
286
|
+
: [...interpolations].find((name) => !supportedInterpolationNames.has(name));
|
|
287
|
+
const hasDynamicInputs = params.length > 0 ||
|
|
288
|
+
hasUnsupportedComposeFiles ||
|
|
289
|
+
unsupportedInterpolation !== undefined;
|
|
290
|
+
if (!hasDynamicInputs) {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
if (!unsupportedParam && !unsupportedInterpolation && !hasUnsupportedComposeFiles) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
const reasons = [];
|
|
297
|
+
if (unsupportedParam) {
|
|
298
|
+
reasons.push("unsupported install parameter type: " + String(unsupportedParam.param.type ?? "unknown"));
|
|
299
|
+
}
|
|
300
|
+
if (unsupportedInterpolation) {
|
|
301
|
+
reasons.push("unsupported interpolation: " + unsupportedInterpolation);
|
|
302
|
+
}
|
|
303
|
+
if (hasUnsupportedComposeFiles) {
|
|
304
|
+
reasons.push("Compose secret/config (DSM override route is not loaded)");
|
|
305
|
+
}
|
|
306
|
+
const reasonText = reasons.join(", ");
|
|
307
|
+
const dynamicPath = findDynamicComposePath(compose);
|
|
308
|
+
const path = dynamicPath ?? (hasUnsupportedComposeFiles ? "secrets" : "services");
|
|
309
|
+
const undeclaredName = unsupportedInterpolation && !unsupportedInterpolation.startsWith("<")
|
|
310
|
+
? unsupportedInterpolation
|
|
311
|
+
: undefined;
|
|
312
|
+
const interpolationSuggestion = undeclaredName
|
|
313
|
+
? `請在 synology-app.yaml 的 install 宣告 ${undeclaredName},type 為 string 或 boolean;或從 Compose 移除該引用。`
|
|
314
|
+
: "請只使用 ${NAME} 形式,並在 synology-app.yaml 的 install 宣告對應的 string 或 boolean parameter。";
|
|
315
|
+
return makeError("COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED", composeSpanForPath(compose, composeFile, path), "Compose dynamic route 含有不在 D1/D2 白名單內的輸入(" +
|
|
316
|
+
reasonText +
|
|
317
|
+
");這條 route 會拒收,不會靜默落回 Broker fallback。", undeclaredName
|
|
318
|
+
? interpolationSuggestion
|
|
319
|
+
: `${interpolationSuggestion} secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收,請移除後重新執行 validate。`);
|
|
320
|
+
}
|
|
321
|
+
function parsedComposeForAnalysis(compose) {
|
|
322
|
+
const source = composeSources.get(compose) ?? "";
|
|
323
|
+
const document = composeDocuments.get(compose) ?? parseDocument(source);
|
|
324
|
+
return { compose, source, document };
|
|
325
|
+
}
|
|
326
|
+
function hasComposeSecretsOrConfigs(compose) {
|
|
327
|
+
return Boolean((compose.secrets && Object.keys(compose.secrets).length > 0) ||
|
|
328
|
+
(compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0));
|
|
329
|
+
}
|
|
330
|
+
function findDynamicComposePath(compose) {
|
|
331
|
+
const path = findInterpolationPath(compose, []);
|
|
332
|
+
if (!path || path.length === 0) {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
if (path[0] === "services" && path.length >= 3) {
|
|
336
|
+
return path.slice(0, 3).join(".");
|
|
337
|
+
}
|
|
338
|
+
return path.join(".");
|
|
339
|
+
}
|
|
340
|
+
function findInterpolationPath(value, path) {
|
|
341
|
+
if (typeof value === "string" && /\$\{([^}]+)\}/.test(value)) {
|
|
342
|
+
return path;
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(value)) {
|
|
345
|
+
for (let index = 0; index < value.length; index++) {
|
|
346
|
+
const found = findInterpolationPath(value[index], [...path, String(index)]);
|
|
347
|
+
if (found) {
|
|
348
|
+
return found;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
if (value && typeof value === "object") {
|
|
354
|
+
for (const [key, child] of Object.entries(value)) {
|
|
355
|
+
const found = findInterpolationPath(child, [...path, key]);
|
|
356
|
+
if (found) {
|
|
357
|
+
return found;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
function composeSpanForPath(compose, composeFile, path) {
|
|
364
|
+
const fallback = composeSpan(composeFile, 1, 1, path);
|
|
365
|
+
const document = composeDocuments.get(compose);
|
|
366
|
+
const source = composeSources.get(compose);
|
|
367
|
+
if (!document || source === undefined) {
|
|
368
|
+
return fallback;
|
|
369
|
+
}
|
|
370
|
+
const offset = composeKeyOffset(document.contents, path.split("."));
|
|
371
|
+
if (offset === undefined) {
|
|
372
|
+
return fallback;
|
|
373
|
+
}
|
|
374
|
+
const position = lineColumnFromSource(source, offset);
|
|
375
|
+
return composeSpan(composeFile, position.line, position.column, path);
|
|
376
|
+
}
|
|
377
|
+
function composeKeyOffset(node, path) {
|
|
378
|
+
let current = node;
|
|
379
|
+
for (const segment of path) {
|
|
380
|
+
const items = current && Array.isArray(current.items) ? current.items : [];
|
|
381
|
+
const pair = items.find((item) => {
|
|
382
|
+
const key = item?.key;
|
|
383
|
+
return key && String(key.value) === segment;
|
|
384
|
+
});
|
|
385
|
+
if (!pair) {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
if (segment === path[path.length - 1]) {
|
|
389
|
+
return pair.key?.range?.[0];
|
|
390
|
+
}
|
|
391
|
+
current = pair.value;
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
function lineColumnFromSource(source, offset) {
|
|
396
|
+
let line = 1;
|
|
397
|
+
let column = 1;
|
|
398
|
+
for (let index = 0; index < offset && index < source.length; index++) {
|
|
399
|
+
if (source[index] === "\n") {
|
|
400
|
+
line++;
|
|
401
|
+
column = 1;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
column++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { line, column };
|
|
408
|
+
}
|
|
409
|
+
function dedupeValidationErrors(errors) {
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
return errors.filter((error) => {
|
|
412
|
+
const key = validationErrorKey(error);
|
|
413
|
+
if (seen.has(key)) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
seen.add(key);
|
|
417
|
+
return true;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function validationErrorKey(error) {
|
|
421
|
+
return [error.code, error.span.file, error.span.line, error.span.column, error.span.path].join("\u0000");
|
|
422
|
+
}
|
|
80
423
|
export function collectComposeInterpolations(compose) {
|
|
81
424
|
const names = new Set();
|
|
82
425
|
walkForInterpolation(compose, names);
|
|
@@ -84,7 +427,7 @@ export function collectComposeInterpolations(compose) {
|
|
|
84
427
|
}
|
|
85
428
|
function walkForInterpolation(value, names) {
|
|
86
429
|
if (typeof value === "string") {
|
|
87
|
-
for (const match of value.matchAll(/\$\{([A-
|
|
430
|
+
for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
88
431
|
names.add(match[1]);
|
|
89
432
|
}
|
|
90
433
|
return;
|
|
@@ -103,25 +446,73 @@ function walkForInterpolation(value, names) {
|
|
|
103
446
|
}
|
|
104
447
|
export function validateComposeSecurity(compose, composeFile, options) {
|
|
105
448
|
const errors = [];
|
|
449
|
+
const contract = composeContracts.get(compose);
|
|
450
|
+
const route = contract
|
|
451
|
+
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
|
|
452
|
+
: undefined;
|
|
453
|
+
const routeErrors = route
|
|
454
|
+
? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
|
|
455
|
+
: [];
|
|
456
|
+
errors.push(...routeErrors);
|
|
457
|
+
if (route?.route === "broker-installer" && hasImmutableServiceImages(compose)) {
|
|
458
|
+
errors.push(brokerInstallerRouteWarning(compose, composeFile));
|
|
459
|
+
}
|
|
460
|
+
const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
|
|
106
461
|
if (!compose.services) {
|
|
107
462
|
return errors;
|
|
108
463
|
}
|
|
109
464
|
const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
|
|
110
465
|
for (const finding of undeclaredPrivilegeFindings(compose, options.declaredPrivilege)) {
|
|
111
|
-
|
|
466
|
+
const error = makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), finding.message, finding.suggestion, undeclaredSeverity);
|
|
467
|
+
if (!routeErrorKeys.has(validationErrorKey(error))) {
|
|
468
|
+
errors.push(error);
|
|
469
|
+
}
|
|
112
470
|
}
|
|
113
471
|
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
114
472
|
const basePath = `services.${serviceName}`;
|
|
115
|
-
|
|
473
|
+
for (const error of validateSocketMounts(compose, service, composeFile, basePath)) {
|
|
474
|
+
if (!routeErrorKeys.has(validationErrorKey(error))) {
|
|
475
|
+
errors.push(error);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
116
478
|
const dependsOn = normalizeDependsOn(service.depends_on);
|
|
117
479
|
for (const dependency of dependsOn) {
|
|
118
480
|
if (!compose.services?.[dependency]) {
|
|
119
|
-
errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND",
|
|
481
|
+
errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpanForPath(compose, composeFile, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
|
|
120
482
|
}
|
|
121
483
|
}
|
|
122
484
|
}
|
|
123
485
|
return errors;
|
|
124
486
|
}
|
|
487
|
+
const COMMAND_ARGUMENT_FIELDS = ["command", "entrypoint"];
|
|
488
|
+
const PURE_NUMERIC_STRING = /^[0-9]+$/;
|
|
489
|
+
/**
|
|
490
|
+
* DSM's Docker Project resource worker can lose YAML quotes while acquiring a
|
|
491
|
+
* project. A quoted numeric command argument can therefore become a number;
|
|
492
|
+
* reject that platform-invalid shape before it reaches the NAS.
|
|
493
|
+
*/
|
|
494
|
+
export function validateComposeNumericCommandArgs(compose, composeFile) {
|
|
495
|
+
const errors = [];
|
|
496
|
+
if (!compose.services) {
|
|
497
|
+
return errors;
|
|
498
|
+
}
|
|
499
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
500
|
+
const basePath = `services.${serviceName}`;
|
|
501
|
+
for (const field of COMMAND_ARGUMENT_FIELDS) {
|
|
502
|
+
const value = service[field];
|
|
503
|
+
if (!Array.isArray(value)) {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const numericStrings = value.filter((item) => typeof item === "string" && PURE_NUMERIC_STRING.test(item));
|
|
507
|
+
if (numericStrings.length === 0) {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const quotedValues = numericStrings.map((item) => `「${item}」`).join("、");
|
|
511
|
+
errors.push(makeError("COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.${field}`), `${field} 陣列含純數字字串 ${quotedValues}。此 Compose 會走 DSM Docker Project resource worker;worker 可能在 YAML round-trip 時移除引號並將它轉成整數,導致 project acquire 失敗。`, `請把整個命令包成單一字串,例如 ${field}: ["sh", "-c", "httpd -f -p 8080 -h /srv"],或與 flag 併寫成 "-p8080"。`));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return errors;
|
|
515
|
+
}
|
|
125
516
|
function normalizeDependsOn(value) {
|
|
126
517
|
if (!value) {
|
|
127
518
|
return [];
|
|
@@ -134,13 +525,13 @@ function normalizeDependsOn(value) {
|
|
|
134
525
|
}
|
|
135
526
|
return [String(value)];
|
|
136
527
|
}
|
|
137
|
-
function validateSocketMounts(service, composeFile, basePath) {
|
|
528
|
+
function validateSocketMounts(compose, service, composeFile, basePath) {
|
|
138
529
|
const errors = [];
|
|
139
530
|
const volumes = normalizeVolumes(service.volumes);
|
|
140
531
|
for (const mount of volumes) {
|
|
141
532
|
const source = mount.split(":")[0]?.trim() ?? "";
|
|
142
533
|
if (isForbiddenSocketMount(source)) {
|
|
143
|
-
errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN",
|
|
534
|
+
errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN", composeSpanForPath(compose, composeFile, `${basePath}.volumes`), "Managed Profile 不允許掛載 Docker 或 Container Manager socket。", "請移除 docker.sock/container-manager socket 的 volume mount。"));
|
|
144
535
|
}
|
|
145
536
|
}
|
|
146
537
|
return errors;
|
|
@@ -166,7 +557,31 @@ function isForbiddenSocketMount(source) {
|
|
|
166
557
|
const normalized = source.replace(/\/+$/, "");
|
|
167
558
|
return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
|
|
168
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* These are the only implicit interpolation names supplied by the platform.
|
|
562
|
+
* The package-only lifecycle materializer writes both paths; every other
|
|
563
|
+
* ${NAME} must be declared in contract.install as string or boolean.
|
|
564
|
+
*/
|
|
169
565
|
const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
|
|
566
|
+
/**
|
|
567
|
+
* Broker-managed OIDC compatibility injects these two names at install time.
|
|
568
|
+
* They are allowed only for an explicitly declared oidc_compat legacy route;
|
|
569
|
+
* the D1 package-only materializer does not write them.
|
|
570
|
+
*/
|
|
571
|
+
const BROKER_OIDC_INTERPOLATION_VARS = new Set([
|
|
572
|
+
"SYNOPKGLAND_OIDC_ISSUER",
|
|
573
|
+
"SYNOPKGLAND_OIDC_CLIENT_ID",
|
|
574
|
+
]);
|
|
575
|
+
function hasBrokerManagedOidc(contract) {
|
|
576
|
+
if (!contract.integration || typeof contract.integration !== "object") {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
const identity = contract.integration.identity;
|
|
580
|
+
if (!identity || typeof identity !== "object") {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
return Boolean(identity.oidc_compat);
|
|
584
|
+
}
|
|
170
585
|
const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
|
|
171
586
|
const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
172
587
|
function collectNamedVolumeNames(compose) {
|
|
@@ -192,10 +607,11 @@ export function validateComposeHostBinds(compose, composeFile, context) {
|
|
|
192
607
|
return errors;
|
|
193
608
|
}
|
|
194
609
|
const namedVolumes = collectNamedVolumeNames(compose);
|
|
610
|
+
const projectRoot = composeProjectRoots.get(compose) ?? dirname(resolve(composeFile));
|
|
195
611
|
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
196
612
|
const spanPath = `services.${serviceName}.volumes`;
|
|
197
613
|
for (const mount of parseVolumeMounts(service.volumes)) {
|
|
198
|
-
const error = classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath);
|
|
614
|
+
const error = classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot);
|
|
199
615
|
if (error) {
|
|
200
616
|
errors.push(error);
|
|
201
617
|
}
|
|
@@ -251,12 +667,12 @@ function parseShortVolumeSource(entry) {
|
|
|
251
667
|
}
|
|
252
668
|
return trimmed.slice(0, colon).trim();
|
|
253
669
|
}
|
|
254
|
-
function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath) {
|
|
670
|
+
function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot) {
|
|
255
671
|
const source = mount.source;
|
|
256
672
|
if (!source || isForbiddenSocketMount(source)) {
|
|
257
673
|
return null;
|
|
258
674
|
}
|
|
259
|
-
const span =
|
|
675
|
+
const span = composeSpanForPath(compose, composeFile, spanPath);
|
|
260
676
|
if (source.includes("$")) {
|
|
261
677
|
const match = source.match(INTERPOLATED_SOURCE);
|
|
262
678
|
const varName = match?.[1];
|
|
@@ -264,16 +680,21 @@ function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPat
|
|
|
264
680
|
if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
|
|
265
681
|
return invalidBindSuffix(span);
|
|
266
682
|
}
|
|
267
|
-
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
683
|
+
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
684
|
+
context.sharedFolderParams.has(varName) ||
|
|
685
|
+
context.homesParams?.has(varName)) {
|
|
268
686
|
return null;
|
|
269
687
|
}
|
|
270
688
|
if (context.installParamNames.has(varName)) {
|
|
271
|
-
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}
|
|
689
|
+
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}、type 為 shared_folder 的 install 參數,或 type 為 homes 的平台解析槽。");
|
|
272
690
|
}
|
|
273
|
-
return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder。`);
|
|
691
|
+
return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder 或 homes。`);
|
|
692
|
+
}
|
|
693
|
+
if (isProjectRelativePath(source, projectRoot)) {
|
|
694
|
+
return null;
|
|
274
695
|
}
|
|
275
696
|
if (isLiteralHostPath(source)) {
|
|
276
|
-
return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder,並以 ${參數名} 作為 bind source。");
|
|
697
|
+
return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder 或 homes,並以 ${參數名} 作為 bind source。");
|
|
277
698
|
}
|
|
278
699
|
if (mount.type === "tmpfs") {
|
|
279
700
|
return null;
|
|
@@ -307,6 +728,49 @@ function isValidatedBindSuffix(suffix) {
|
|
|
307
728
|
function isLiteralHostPath(source) {
|
|
308
729
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
309
730
|
}
|
|
731
|
+
function isProjectRelativePath(source, projectRoot) {
|
|
732
|
+
if (!source.startsWith("./")) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const relativeSource = source.slice(2);
|
|
736
|
+
if (relativeSource.length === 0 ||
|
|
737
|
+
relativeSource.includes("\0") ||
|
|
738
|
+
relativeSource.split("/").some((segment) => segment.length === 0 || segment === "..")) {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
const projectPath = resolve(projectRoot);
|
|
742
|
+
const candidatePath = resolve(projectPath, source);
|
|
743
|
+
if (!isStrictDescendant(candidatePath, projectPath)) {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
const realProjectPath = existingRealPath(projectPath);
|
|
747
|
+
const realCandidatePath = existingRealPath(candidatePath);
|
|
748
|
+
return (realProjectPath !== undefined &&
|
|
749
|
+
realCandidatePath !== undefined &&
|
|
750
|
+
isStrictDescendant(realCandidatePath, realProjectPath));
|
|
751
|
+
}
|
|
752
|
+
function isStrictDescendant(candidatePath, projectPath) {
|
|
753
|
+
const rel = relative(projectPath, candidatePath);
|
|
754
|
+
return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
|
|
755
|
+
}
|
|
756
|
+
function existingRealPath(path) {
|
|
757
|
+
let current = path;
|
|
758
|
+
while (!existsSync(current)) {
|
|
759
|
+
const parent = dirname(current);
|
|
760
|
+
if (parent === current) {
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
current = parent;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const realCurrent = realpathSync(current);
|
|
767
|
+
const missingSuffix = relative(current, path);
|
|
768
|
+
return resolve(realCurrent, missingSuffix || ".");
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
310
774
|
const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
|
|
311
775
|
const ROOT_USERS = new Set(["0", "0:0", "root"]);
|
|
312
776
|
export function validateComposeRunAs(compose, composeFile, context) {
|
|
@@ -320,7 +784,7 @@ export function validateComposeRunAs(compose, composeFile, context) {
|
|
|
320
784
|
continue;
|
|
321
785
|
}
|
|
322
786
|
const user = raw.trim();
|
|
323
|
-
const span =
|
|
787
|
+
const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
|
|
324
788
|
if (user.includes("${")) {
|
|
325
789
|
continue;
|
|
326
790
|
}
|
|
@@ -339,7 +803,7 @@ export function validateAdminPortMapping(adminport, compose, composeFile) {
|
|
|
339
803
|
const services = published.get(adminport) ?? [];
|
|
340
804
|
if (services.length !== 1) {
|
|
341
805
|
return [
|
|
342
|
-
makeError("CONTRACT_ADMINPORT_UNMAPPED",
|
|
806
|
+
makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpanForPath(compose, composeFile, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
|
|
343
807
|
];
|
|
344
808
|
}
|
|
345
809
|
return [];
|
package/dist/contract/load.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { AppContract, InstallParameter } from "../types.js";
|
|
|
3
3
|
export interface ParsedContract {
|
|
4
4
|
contract: AppContract;
|
|
5
5
|
doc: Document;
|
|
6
|
+
contractPath?: string;
|
|
6
7
|
}
|
|
7
8
|
export declare function loadContract(contractPath: string): ParsedContract;
|
|
8
9
|
export declare function getScalarPosition(doc: Document, keyPath: string[]): {
|
package/dist/contract/load.js
CHANGED
|
@@ -4,7 +4,7 @@ export function loadContract(contractPath) {
|
|
|
4
4
|
const source = readFileSync(contractPath, "utf8");
|
|
5
5
|
const doc = parseDocument(source);
|
|
6
6
|
const contract = (doc.toJSON() ?? {});
|
|
7
|
-
return { contract, doc };
|
|
7
|
+
return { contract, doc, contractPath };
|
|
8
8
|
}
|
|
9
9
|
export function getScalarPosition(doc, keyPath) {
|
|
10
10
|
const node = findNode(doc.contents, keyPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ComposeDocument, ValidationError } from "../types.js";
|
|
2
|
+
/** Install type for a deliberate, install-time snapshot of every DSM Share. */
|
|
3
|
+
export declare const POSIX_ALL_SHARES_TYPE: "posix_all_shares";
|
|
4
|
+
export declare function isPosixAllSharesType(type: unknown): boolean;
|
|
5
|
+
export declare function collectPosixAllSharesParameters(installParams: ReadonlyArray<{
|
|
6
|
+
name: string;
|
|
7
|
+
param: {
|
|
8
|
+
type?: unknown;
|
|
9
|
+
};
|
|
10
|
+
}>): Set<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Validate the extra semantics of the all-shares marker without changing the
|
|
13
|
+
* shared host-bind analyzer. The latter is intentionally left as the single
|
|
14
|
+
* authority for absolute paths, suffix syntax, and named volumes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validatePosixAllSharesSources(compose: ComposeDocument, composeFile: string, declaredParameters: ReadonlySet<string>): ValidationError[];
|