@synopackageland/cli 0.3.0 → 0.3.2
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 +210 -14
- 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 +41 -1
- package/dist/compose/analyze.js +458 -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 +67 -0
- package/dist/contract/web-launch.d.ts +6 -0
- package/dist/contract/web-launch.js +25 -0
- package/dist/deploy/deploy.d.ts +3 -0
- package/dist/deploy/deploy.js +38 -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 +5 -0
- package/dist/deploy/spk-version.js +50 -18
- 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 +827 -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 +12 -0
- package/dist/host/catalog-install.js +83 -17
- package/dist/host/credentials.js +35 -5
- package/dist/host/dsm-client.d.ts +12 -0
- package/dist/host/dsm-client.js +56 -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 +4 -0
- package/dist/publish/curated-release.js +3 -3
- package/dist/publish/platform-service-release.d.ts +60 -0
- package/dist/publish/platform-service-release.js +333 -0
- package/dist/publish/preflight.js +14 -1
- 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
|
@@ -1,20 +1,60 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type Document } from "yaml";
|
|
2
|
+
import type { AppContract, ComposeDocument, ComposeService, ValidationError } from "../types.js";
|
|
2
3
|
import { type PrivilegeFlag } from "./privilege.js";
|
|
3
4
|
export interface ParsedCompose {
|
|
4
5
|
compose: ComposeDocument;
|
|
5
6
|
source: string;
|
|
7
|
+
document: Document;
|
|
6
8
|
}
|
|
7
9
|
export declare function loadCompose(composePath: string): ParsedCompose | null;
|
|
8
10
|
export declare function listServiceNames(compose: ComposeDocument): string[];
|
|
9
11
|
export declare function extractPublishedTcpPorts(service: ComposeService): number[];
|
|
10
12
|
export declare function collectPublishedTcpPorts(compose: ComposeDocument): Map<number, string[]>;
|
|
13
|
+
export type ResourceWorkerIneligibleReason = "HAS_INSTALL_PARAMETERS" | "HAS_COMPOSE_INTERPOLATION" | "HAS_COMPOSE_SECRETS_OR_CONFIGS" | "HAS_BUILD_STANZA" | "HAS_PRIVILEGED_CAPABILITY" | "WEB_APP_MISSING_OR_AMBIGUOUS_ADMINPORT";
|
|
14
|
+
export interface ResourceWorkerEligibility {
|
|
15
|
+
eligible: boolean;
|
|
16
|
+
reasons: ResourceWorkerIneligibleReason[];
|
|
17
|
+
adminport?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Checks if a Compose contract and document are eligible for the zero-config DSM resource-worker path.
|
|
21
|
+
* Zero-config slice rejects:
|
|
22
|
+
* - any install parameter
|
|
23
|
+
* - any ${...} interpolation anywhere in compose document
|
|
24
|
+
* - compose secrets or configs
|
|
25
|
+
* - build: stanzas
|
|
26
|
+
* - privileged / host network / device / root-like capabilities
|
|
27
|
+
* - any App without a unique literal published adminport status probe
|
|
28
|
+
*/
|
|
29
|
+
export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
|
|
30
|
+
export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
|
|
31
|
+
export interface ComposeRouteClassification {
|
|
32
|
+
route: ComposeRoute;
|
|
33
|
+
eligibility: ResourceWorkerEligibility;
|
|
34
|
+
errors: ValidationError[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Chooses the local route D0 can safely emit. The first dynamic slice is
|
|
38
|
+
* deliberately small: only string/boolean wizard answers interpolated as
|
|
39
|
+
* ordinary Compose values. Secrets/configs remain rejected because the live
|
|
40
|
+
* DSM worker does not load docker-compose.override.yml, and no secret may be
|
|
41
|
+
* placed in the project .env.
|
|
42
|
+
*/
|
|
43
|
+
export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
|
|
11
44
|
export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
|
|
12
45
|
export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
|
|
13
46
|
profile: "personal" | "listing";
|
|
14
47
|
declaredPrivilege: ReadonlySet<PrivilegeFlag>;
|
|
15
48
|
}): ValidationError[];
|
|
49
|
+
/**
|
|
50
|
+
* DSM's Docker Project resource worker can lose YAML quotes while acquiring a
|
|
51
|
+
* project. A quoted numeric command argument can therefore become a number;
|
|
52
|
+
* reject that platform-invalid shape before it reaches the NAS.
|
|
53
|
+
*/
|
|
54
|
+
export declare function validateComposeNumericCommandArgs(compose: ComposeDocument, composeFile: string): ValidationError[];
|
|
16
55
|
export interface HostBindContext {
|
|
17
56
|
sharedFolderParams: ReadonlySet<string>;
|
|
57
|
+
homesParams?: ReadonlySet<string>;
|
|
18
58
|
installParamNames: ReadonlySet<string>;
|
|
19
59
|
}
|
|
20
60
|
export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;
|
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,304 @@ 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
|
+
return { route: "dynamic", eligibility, errors: [] };
|
|
208
|
+
}
|
|
209
|
+
function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
210
|
+
const { compose } = parsed;
|
|
211
|
+
const errors = collectPermanentRouteErrors(contract, parsed, composeFile);
|
|
212
|
+
const installParams = collectInstallParameters(contract);
|
|
213
|
+
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
214
|
+
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
215
|
+
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
216
|
+
errors.push(...validateComposeHostBinds(compose, composeFile, {
|
|
217
|
+
sharedFolderParams,
|
|
218
|
+
homesParams,
|
|
219
|
+
installParamNames,
|
|
220
|
+
}));
|
|
221
|
+
return dedupeValidationErrors(errors);
|
|
222
|
+
}
|
|
223
|
+
function collectPermanentRouteErrors(contract, parsed, composeFile) {
|
|
224
|
+
if (contract.adminport === undefined) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const { compose } = parsed;
|
|
228
|
+
const errors = [];
|
|
229
|
+
if (compose.services) {
|
|
230
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
231
|
+
const basePath = `services.${serviceName}`;
|
|
232
|
+
if ("build" in service && service.build !== undefined) {
|
|
233
|
+
const path = `${basePath}.build`;
|
|
234
|
+
errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
|
|
235
|
+
}
|
|
236
|
+
errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const finding of scanComposePrivilege(compose)) {
|
|
240
|
+
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。"));
|
|
241
|
+
}
|
|
242
|
+
return errors;
|
|
243
|
+
}
|
|
244
|
+
function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
245
|
+
const { compose } = parsed;
|
|
246
|
+
const params = collectInstallParameters(contract);
|
|
247
|
+
const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
|
|
248
|
+
const supportedParamNames = new Set(params
|
|
249
|
+
.filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
250
|
+
(param.type === "string" || param.type === "boolean"))
|
|
251
|
+
.map(({ name }) => name));
|
|
252
|
+
const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
|
|
253
|
+
(param.type !== "string" && param.type !== "boolean"));
|
|
254
|
+
const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
|
|
255
|
+
? BROKER_OIDC_INTERPOLATION_VARS
|
|
256
|
+
: [];
|
|
257
|
+
const supportedInterpolationNames = new Set([
|
|
258
|
+
...supportedParamNames,
|
|
259
|
+
...PLATFORM_BIND_VARS,
|
|
260
|
+
...brokerOidcInterpolationNames,
|
|
261
|
+
]);
|
|
262
|
+
const interpolations = collectComposeInterpolations(compose);
|
|
263
|
+
const hasComposeInterpolation = hasInterpolation(compose);
|
|
264
|
+
const unsupportedInterpolation = !hasComposeInterpolation
|
|
265
|
+
? undefined
|
|
266
|
+
: interpolations.size === 0
|
|
267
|
+
? "<unsupported interpolation syntax>"
|
|
268
|
+
: [...interpolations].find((name) => !supportedInterpolationNames.has(name));
|
|
269
|
+
const hasDynamicInputs = params.length > 0 ||
|
|
270
|
+
hasUnsupportedComposeFiles ||
|
|
271
|
+
unsupportedInterpolation !== undefined;
|
|
272
|
+
if (!hasDynamicInputs) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (!unsupportedParam && !unsupportedInterpolation && !hasUnsupportedComposeFiles) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const reasons = [];
|
|
279
|
+
if (unsupportedParam) {
|
|
280
|
+
reasons.push("unsupported install parameter type: " + String(unsupportedParam.param.type ?? "unknown"));
|
|
281
|
+
}
|
|
282
|
+
if (unsupportedInterpolation) {
|
|
283
|
+
reasons.push("unsupported interpolation: " + unsupportedInterpolation);
|
|
284
|
+
}
|
|
285
|
+
if (hasUnsupportedComposeFiles) {
|
|
286
|
+
reasons.push("Compose secret/config (DSM override route is not loaded)");
|
|
287
|
+
}
|
|
288
|
+
const reasonText = reasons.join(", ");
|
|
289
|
+
const dynamicPath = findDynamicComposePath(compose);
|
|
290
|
+
const path = dynamicPath ?? (hasUnsupportedComposeFiles ? "secrets" : "services");
|
|
291
|
+
const undeclaredName = unsupportedInterpolation && !unsupportedInterpolation.startsWith("<")
|
|
292
|
+
? unsupportedInterpolation
|
|
293
|
+
: undefined;
|
|
294
|
+
const interpolationSuggestion = undeclaredName
|
|
295
|
+
? `請在 synology-app.yaml 的 install 宣告 ${undeclaredName},type 為 string 或 boolean;或從 Compose 移除該引用。`
|
|
296
|
+
: "請只使用 ${NAME} 形式,並在 synology-app.yaml 的 install 宣告對應的 string 或 boolean parameter。";
|
|
297
|
+
return makeError("COMPOSE_DYNAMIC_ROUTE_UNSUPPORTED", composeSpanForPath(compose, composeFile, path), "Compose dynamic route 含有不在 D1/D2 白名單內的輸入(" +
|
|
298
|
+
reasonText +
|
|
299
|
+
");這條 route 會拒收,不會靜默落回 Broker fallback。", undeclaredName
|
|
300
|
+
? interpolationSuggestion
|
|
301
|
+
: `${interpolationSuggestion} secret/config 因 DSM worker 不讀 docker-compose.override.yml 而拒收,請移除後重新執行 validate。`);
|
|
302
|
+
}
|
|
303
|
+
function parsedComposeForAnalysis(compose) {
|
|
304
|
+
const source = composeSources.get(compose) ?? "";
|
|
305
|
+
const document = composeDocuments.get(compose) ?? parseDocument(source);
|
|
306
|
+
return { compose, source, document };
|
|
307
|
+
}
|
|
308
|
+
function hasComposeSecretsOrConfigs(compose) {
|
|
309
|
+
return Boolean((compose.secrets && Object.keys(compose.secrets).length > 0) ||
|
|
310
|
+
(compose.configs && typeof compose.configs === "object" && Object.keys(compose.configs).length > 0));
|
|
311
|
+
}
|
|
312
|
+
function findDynamicComposePath(compose) {
|
|
313
|
+
const path = findInterpolationPath(compose, []);
|
|
314
|
+
if (!path || path.length === 0) {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
if (path[0] === "services" && path.length >= 3) {
|
|
318
|
+
return path.slice(0, 3).join(".");
|
|
319
|
+
}
|
|
320
|
+
return path.join(".");
|
|
321
|
+
}
|
|
322
|
+
function findInterpolationPath(value, path) {
|
|
323
|
+
if (typeof value === "string" && /\$\{([^}]+)\}/.test(value)) {
|
|
324
|
+
return path;
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(value)) {
|
|
327
|
+
for (let index = 0; index < value.length; index++) {
|
|
328
|
+
const found = findInterpolationPath(value[index], [...path, String(index)]);
|
|
329
|
+
if (found) {
|
|
330
|
+
return found;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
if (value && typeof value === "object") {
|
|
336
|
+
for (const [key, child] of Object.entries(value)) {
|
|
337
|
+
const found = findInterpolationPath(child, [...path, key]);
|
|
338
|
+
if (found) {
|
|
339
|
+
return found;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
function composeSpanForPath(compose, composeFile, path) {
|
|
346
|
+
const fallback = composeSpan(composeFile, 1, 1, path);
|
|
347
|
+
const document = composeDocuments.get(compose);
|
|
348
|
+
const source = composeSources.get(compose);
|
|
349
|
+
if (!document || source === undefined) {
|
|
350
|
+
return fallback;
|
|
351
|
+
}
|
|
352
|
+
const offset = composeKeyOffset(document.contents, path.split("."));
|
|
353
|
+
if (offset === undefined) {
|
|
354
|
+
return fallback;
|
|
355
|
+
}
|
|
356
|
+
const position = lineColumnFromSource(source, offset);
|
|
357
|
+
return composeSpan(composeFile, position.line, position.column, path);
|
|
358
|
+
}
|
|
359
|
+
function composeKeyOffset(node, path) {
|
|
360
|
+
let current = node;
|
|
361
|
+
for (const segment of path) {
|
|
362
|
+
const items = current && Array.isArray(current.items) ? current.items : [];
|
|
363
|
+
const pair = items.find((item) => {
|
|
364
|
+
const key = item?.key;
|
|
365
|
+
return key && String(key.value) === segment;
|
|
366
|
+
});
|
|
367
|
+
if (!pair) {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
if (segment === path[path.length - 1]) {
|
|
371
|
+
return pair.key?.range?.[0];
|
|
372
|
+
}
|
|
373
|
+
current = pair.value;
|
|
374
|
+
}
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
377
|
+
function lineColumnFromSource(source, offset) {
|
|
378
|
+
let line = 1;
|
|
379
|
+
let column = 1;
|
|
380
|
+
for (let index = 0; index < offset && index < source.length; index++) {
|
|
381
|
+
if (source[index] === "\n") {
|
|
382
|
+
line++;
|
|
383
|
+
column = 1;
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
column++;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { line, column };
|
|
390
|
+
}
|
|
391
|
+
function dedupeValidationErrors(errors) {
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
return errors.filter((error) => {
|
|
394
|
+
const key = validationErrorKey(error);
|
|
395
|
+
if (seen.has(key)) {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
seen.add(key);
|
|
399
|
+
return true;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function validationErrorKey(error) {
|
|
403
|
+
return [error.code, error.span.file, error.span.line, error.span.column, error.span.path].join("\u0000");
|
|
404
|
+
}
|
|
80
405
|
export function collectComposeInterpolations(compose) {
|
|
81
406
|
const names = new Set();
|
|
82
407
|
walkForInterpolation(compose, names);
|
|
@@ -84,7 +409,7 @@ export function collectComposeInterpolations(compose) {
|
|
|
84
409
|
}
|
|
85
410
|
function walkForInterpolation(value, names) {
|
|
86
411
|
if (typeof value === "string") {
|
|
87
|
-
for (const match of value.matchAll(/\$\{([A-
|
|
412
|
+
for (const match of value.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
|
|
88
413
|
names.add(match[1]);
|
|
89
414
|
}
|
|
90
415
|
return;
|
|
@@ -103,25 +428,67 @@ function walkForInterpolation(value, names) {
|
|
|
103
428
|
}
|
|
104
429
|
export function validateComposeSecurity(compose, composeFile, options) {
|
|
105
430
|
const errors = [];
|
|
431
|
+
const contract = composeContracts.get(compose);
|
|
432
|
+
const routeErrors = contract
|
|
433
|
+
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile).errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
|
|
434
|
+
: [];
|
|
435
|
+
errors.push(...routeErrors);
|
|
436
|
+
const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
|
|
106
437
|
if (!compose.services) {
|
|
107
438
|
return errors;
|
|
108
439
|
}
|
|
109
440
|
const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
|
|
110
441
|
for (const finding of undeclaredPrivilegeFindings(compose, options.declaredPrivilege)) {
|
|
111
|
-
|
|
442
|
+
const error = makeError(finding.code, composeSpanForPath(compose, composeFile, finding.path), finding.message, finding.suggestion, undeclaredSeverity);
|
|
443
|
+
if (!routeErrorKeys.has(validationErrorKey(error))) {
|
|
444
|
+
errors.push(error);
|
|
445
|
+
}
|
|
112
446
|
}
|
|
113
447
|
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
114
448
|
const basePath = `services.${serviceName}`;
|
|
115
|
-
|
|
449
|
+
for (const error of validateSocketMounts(compose, service, composeFile, basePath)) {
|
|
450
|
+
if (!routeErrorKeys.has(validationErrorKey(error))) {
|
|
451
|
+
errors.push(error);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
116
454
|
const dependsOn = normalizeDependsOn(service.depends_on);
|
|
117
455
|
for (const dependency of dependsOn) {
|
|
118
456
|
if (!compose.services?.[dependency]) {
|
|
119
|
-
errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND",
|
|
457
|
+
errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpanForPath(compose, composeFile, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
|
|
120
458
|
}
|
|
121
459
|
}
|
|
122
460
|
}
|
|
123
461
|
return errors;
|
|
124
462
|
}
|
|
463
|
+
const COMMAND_ARGUMENT_FIELDS = ["command", "entrypoint"];
|
|
464
|
+
const PURE_NUMERIC_STRING = /^[0-9]+$/;
|
|
465
|
+
/**
|
|
466
|
+
* DSM's Docker Project resource worker can lose YAML quotes while acquiring a
|
|
467
|
+
* project. A quoted numeric command argument can therefore become a number;
|
|
468
|
+
* reject that platform-invalid shape before it reaches the NAS.
|
|
469
|
+
*/
|
|
470
|
+
export function validateComposeNumericCommandArgs(compose, composeFile) {
|
|
471
|
+
const errors = [];
|
|
472
|
+
if (!compose.services) {
|
|
473
|
+
return errors;
|
|
474
|
+
}
|
|
475
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
476
|
+
const basePath = `services.${serviceName}`;
|
|
477
|
+
for (const field of COMMAND_ARGUMENT_FIELDS) {
|
|
478
|
+
const value = service[field];
|
|
479
|
+
if (!Array.isArray(value)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const numericStrings = value.filter((item) => typeof item === "string" && PURE_NUMERIC_STRING.test(item));
|
|
483
|
+
if (numericStrings.length === 0) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const quotedValues = numericStrings.map((item) => `「${item}」`).join("、");
|
|
487
|
+
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"。`));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return errors;
|
|
491
|
+
}
|
|
125
492
|
function normalizeDependsOn(value) {
|
|
126
493
|
if (!value) {
|
|
127
494
|
return [];
|
|
@@ -134,13 +501,13 @@ function normalizeDependsOn(value) {
|
|
|
134
501
|
}
|
|
135
502
|
return [String(value)];
|
|
136
503
|
}
|
|
137
|
-
function validateSocketMounts(service, composeFile, basePath) {
|
|
504
|
+
function validateSocketMounts(compose, service, composeFile, basePath) {
|
|
138
505
|
const errors = [];
|
|
139
506
|
const volumes = normalizeVolumes(service.volumes);
|
|
140
507
|
for (const mount of volumes) {
|
|
141
508
|
const source = mount.split(":")[0]?.trim() ?? "";
|
|
142
509
|
if (isForbiddenSocketMount(source)) {
|
|
143
|
-
errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN",
|
|
510
|
+
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
511
|
}
|
|
145
512
|
}
|
|
146
513
|
return errors;
|
|
@@ -166,7 +533,31 @@ function isForbiddenSocketMount(source) {
|
|
|
166
533
|
const normalized = source.replace(/\/+$/, "");
|
|
167
534
|
return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
|
|
168
535
|
}
|
|
536
|
+
/**
|
|
537
|
+
* These are the only implicit interpolation names supplied by the platform.
|
|
538
|
+
* The package-only lifecycle materializer writes both paths; every other
|
|
539
|
+
* ${NAME} must be declared in contract.install as string or boolean.
|
|
540
|
+
*/
|
|
169
541
|
const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
|
|
542
|
+
/**
|
|
543
|
+
* Broker-managed OIDC compatibility injects these two names at install time.
|
|
544
|
+
* They are allowed only for an explicitly declared oidc_compat legacy route;
|
|
545
|
+
* the D1 package-only materializer does not write them.
|
|
546
|
+
*/
|
|
547
|
+
const BROKER_OIDC_INTERPOLATION_VARS = new Set([
|
|
548
|
+
"SYNOPKGLAND_OIDC_ISSUER",
|
|
549
|
+
"SYNOPKGLAND_OIDC_CLIENT_ID",
|
|
550
|
+
]);
|
|
551
|
+
function hasBrokerManagedOidc(contract) {
|
|
552
|
+
if (!contract.integration || typeof contract.integration !== "object") {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const identity = contract.integration.identity;
|
|
556
|
+
if (!identity || typeof identity !== "object") {
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
return Boolean(identity.oidc_compat);
|
|
560
|
+
}
|
|
170
561
|
const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
|
|
171
562
|
const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
172
563
|
function collectNamedVolumeNames(compose) {
|
|
@@ -192,10 +583,11 @@ export function validateComposeHostBinds(compose, composeFile, context) {
|
|
|
192
583
|
return errors;
|
|
193
584
|
}
|
|
194
585
|
const namedVolumes = collectNamedVolumeNames(compose);
|
|
586
|
+
const projectRoot = composeProjectRoots.get(compose) ?? dirname(resolve(composeFile));
|
|
195
587
|
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
196
588
|
const spanPath = `services.${serviceName}.volumes`;
|
|
197
589
|
for (const mount of parseVolumeMounts(service.volumes)) {
|
|
198
|
-
const error = classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath);
|
|
590
|
+
const error = classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot);
|
|
199
591
|
if (error) {
|
|
200
592
|
errors.push(error);
|
|
201
593
|
}
|
|
@@ -251,12 +643,12 @@ function parseShortVolumeSource(entry) {
|
|
|
251
643
|
}
|
|
252
644
|
return trimmed.slice(0, colon).trim();
|
|
253
645
|
}
|
|
254
|
-
function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath) {
|
|
646
|
+
function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile, spanPath, projectRoot) {
|
|
255
647
|
const source = mount.source;
|
|
256
648
|
if (!source || isForbiddenSocketMount(source)) {
|
|
257
649
|
return null;
|
|
258
650
|
}
|
|
259
|
-
const span =
|
|
651
|
+
const span = composeSpanForPath(compose, composeFile, spanPath);
|
|
260
652
|
if (source.includes("$")) {
|
|
261
653
|
const match = source.match(INTERPOLATED_SOURCE);
|
|
262
654
|
const varName = match?.[1];
|
|
@@ -264,16 +656,21 @@ function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPat
|
|
|
264
656
|
if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
|
|
265
657
|
return invalidBindSuffix(span);
|
|
266
658
|
}
|
|
267
|
-
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
659
|
+
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
660
|
+
context.sharedFolderParams.has(varName) ||
|
|
661
|
+
context.homesParams?.has(varName)) {
|
|
268
662
|
return null;
|
|
269
663
|
}
|
|
270
664
|
if (context.installParamNames.has(varName)) {
|
|
271
|
-
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}
|
|
665
|
+
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR}、type 為 shared_folder 的 install 參數,或 type 為 homes 的平台解析槽。");
|
|
272
666
|
}
|
|
273
|
-
return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder。`);
|
|
667
|
+
return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder 或 homes。`);
|
|
668
|
+
}
|
|
669
|
+
if (isProjectRelativePath(source, projectRoot)) {
|
|
670
|
+
return null;
|
|
274
671
|
}
|
|
275
672
|
if (isLiteralHostPath(source)) {
|
|
276
|
-
return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder,並以 ${參數名} 作為 bind source。");
|
|
673
|
+
return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder 或 homes,並以 ${參數名} 作為 bind source。");
|
|
277
674
|
}
|
|
278
675
|
if (mount.type === "tmpfs") {
|
|
279
676
|
return null;
|
|
@@ -307,6 +704,49 @@ function isValidatedBindSuffix(suffix) {
|
|
|
307
704
|
function isLiteralHostPath(source) {
|
|
308
705
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
309
706
|
}
|
|
707
|
+
function isProjectRelativePath(source, projectRoot) {
|
|
708
|
+
if (!source.startsWith("./")) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
const relativeSource = source.slice(2);
|
|
712
|
+
if (relativeSource.length === 0 ||
|
|
713
|
+
relativeSource.includes("\0") ||
|
|
714
|
+
relativeSource.split("/").some((segment) => segment.length === 0 || segment === "..")) {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
const projectPath = resolve(projectRoot);
|
|
718
|
+
const candidatePath = resolve(projectPath, source);
|
|
719
|
+
if (!isStrictDescendant(candidatePath, projectPath)) {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
const realProjectPath = existingRealPath(projectPath);
|
|
723
|
+
const realCandidatePath = existingRealPath(candidatePath);
|
|
724
|
+
return (realProjectPath !== undefined &&
|
|
725
|
+
realCandidatePath !== undefined &&
|
|
726
|
+
isStrictDescendant(realCandidatePath, realProjectPath));
|
|
727
|
+
}
|
|
728
|
+
function isStrictDescendant(candidatePath, projectPath) {
|
|
729
|
+
const rel = relative(projectPath, candidatePath);
|
|
730
|
+
return rel.length > 0 && rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep);
|
|
731
|
+
}
|
|
732
|
+
function existingRealPath(path) {
|
|
733
|
+
let current = path;
|
|
734
|
+
while (!existsSync(current)) {
|
|
735
|
+
const parent = dirname(current);
|
|
736
|
+
if (parent === current) {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
current = parent;
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const realCurrent = realpathSync(current);
|
|
743
|
+
const missingSuffix = relative(current, path);
|
|
744
|
+
return resolve(realCurrent, missingSuffix || ".");
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
310
750
|
const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
|
|
311
751
|
const ROOT_USERS = new Set(["0", "0:0", "root"]);
|
|
312
752
|
export function validateComposeRunAs(compose, composeFile, context) {
|
|
@@ -320,7 +760,7 @@ export function validateComposeRunAs(compose, composeFile, context) {
|
|
|
320
760
|
continue;
|
|
321
761
|
}
|
|
322
762
|
const user = raw.trim();
|
|
323
|
-
const span =
|
|
763
|
+
const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
|
|
324
764
|
if (user.includes("${")) {
|
|
325
765
|
continue;
|
|
326
766
|
}
|
|
@@ -339,7 +779,7 @@ export function validateAdminPortMapping(adminport, compose, composeFile) {
|
|
|
339
779
|
const services = published.get(adminport) ?? [];
|
|
340
780
|
if (services.length !== 1) {
|
|
341
781
|
return [
|
|
342
|
-
makeError("CONTRACT_ADMINPORT_UNMAPPED",
|
|
782
|
+
makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpanForPath(compose, composeFile, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
|
|
343
783
|
];
|
|
344
784
|
}
|
|
345
785
|
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[]): {
|