@synopackageland/cli 0.1.0
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/README.md +13 -0
- package/dist/attestation/app-package.d.ts +86 -0
- package/dist/attestation/app-package.js +199 -0
- package/dist/attestation/install-on-host.d.ts +14 -0
- package/dist/attestation/install-on-host.js +52 -0
- package/dist/attestation/keyring.d.ts +21 -0
- package/dist/attestation/keyring.js +168 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +14 -0
- package/dist/build-spk/dsm-arch.d.ts +4 -0
- package/dist/build-spk/dsm-arch.js +32 -0
- package/dist/build-spk/dsm-icons.d.ts +2 -0
- package/dist/build-spk/dsm-icons.js +67 -0
- package/dist/build-spk/generator.d.ts +43 -0
- package/dist/build-spk/generator.js +653 -0
- package/dist/build-spk/native-payload.d.ts +22 -0
- package/dist/build-spk/native-payload.js +654 -0
- package/dist/build-spk/reproducible-archive.d.ts +8 -0
- package/dist/build-spk/reproducible-archive.js +34 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +345 -0
- package/dist/compose/analyze.d.ts +25 -0
- package/dist/compose/analyze.js +346 -0
- package/dist/compose/env.d.ts +7 -0
- package/dist/compose/env.js +36 -0
- package/dist/compose/privilege.d.ts +14 -0
- package/dist/compose/privilege.js +102 -0
- package/dist/compose/runner.d.ts +32 -0
- package/dist/compose/runner.js +84 -0
- package/dist/contract/load.d.ts +25 -0
- package/dist/contract/load.js +87 -0
- package/dist/contract/schema-validate.d.ts +3 -0
- package/dist/contract/schema-validate.js +50 -0
- package/dist/contract/validate.d.ts +3 -0
- package/dist/contract/validate.js +80 -0
- package/dist/contract/web-launch.d.ts +19 -0
- package/dist/contract/web-launch.js +94 -0
- package/dist/deploy/catalog-publish.d.ts +3 -0
- package/dist/deploy/catalog-publish.js +7 -0
- package/dist/deploy/deploy.d.ts +22 -0
- package/dist/deploy/deploy.js +136 -0
- package/dist/deploy/identity-shared.d.ts +3 -0
- package/dist/deploy/identity-shared.js +14 -0
- package/dist/deploy/identity.d.ts +7 -0
- package/dist/deploy/identity.js +20 -0
- package/dist/deploy/package-source.d.ts +7 -0
- package/dist/deploy/package-source.js +17 -0
- package/dist/deploy/run-as-values.d.ts +18 -0
- package/dist/deploy/run-as-values.js +48 -0
- package/dist/deploy/share-values.d.ts +20 -0
- package/dist/deploy/share-values.js +60 -0
- package/dist/deploy/spk-info.d.ts +12 -0
- package/dist/deploy/spk-info.js +41 -0
- package/dist/deploy/spk-version.d.ts +4 -0
- package/dist/deploy/spk-version.js +64 -0
- package/dist/dev.d.ts +13 -0
- package/dist/dev.js +31 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +22 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +18 -0
- package/dist/host/broker-paths.d.ts +1 -0
- package/dist/host/broker-paths.js +1 -0
- package/dist/host/catalog-install.d.ts +59 -0
- package/dist/host/catalog-install.js +326 -0
- package/dist/host/credentials.d.ts +2 -0
- package/dist/host/credentials.js +37 -0
- package/dist/host/digests.d.ts +5 -0
- package/dist/host/digests.js +55 -0
- package/dist/host/dsm-client.d.ts +6 -0
- package/dist/host/dsm-client.js +290 -0
- package/dist/host/dsm-http.d.ts +1 -0
- package/dist/host/dsm-http.js +87 -0
- package/dist/host/fake-nas.d.ts +16 -0
- package/dist/host/fake-nas.js +116 -0
- package/dist/host/open-url.d.ts +5 -0
- package/dist/host/open-url.js +13 -0
- package/dist/host/share-lookup.d.ts +3 -0
- package/dist/host/share-lookup.js +144 -0
- package/dist/host/types.d.ts +67 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/info.d.ts +17 -0
- package/dist/info.js +34 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +31 -0
- package/dist/inspect.d.ts +13 -0
- package/dist/inspect.js +102 -0
- package/dist/load-env.d.ts +1 -0
- package/dist/load-env.js +31 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +20 -0
- package/dist/mock-broker/bootstrap.d.ts +2 -0
- package/dist/mock-broker/bootstrap.js +8 -0
- package/dist/mock-broker/files.d.ts +60 -0
- package/dist/mock-broker/files.js +408 -0
- package/dist/mock-broker/oidc.d.ts +31 -0
- package/dist/mock-broker/oidc.js +161 -0
- package/dist/mock-broker/path.d.ts +9 -0
- package/dist/mock-broker/path.js +50 -0
- package/dist/mock-broker/server.d.ts +19 -0
- package/dist/mock-broker/server.js +350 -0
- package/dist/publish/build-ledger.d.ts +22 -0
- package/dist/publish/build-ledger.js +26 -0
- package/dist/publish/community-path.d.ts +6 -0
- package/dist/publish/community-path.js +41 -0
- package/dist/publish/curated-release.d.ts +78 -0
- package/dist/publish/curated-release.js +693 -0
- package/dist/publish/file-build-ledger.d.ts +18 -0
- package/dist/publish/file-build-ledger.js +268 -0
- package/dist/publish/github-pr.d.ts +25 -0
- package/dist/publish/github-pr.js +85 -0
- package/dist/publish/github-repo.d.ts +8 -0
- package/dist/publish/github-repo.js +21 -0
- package/dist/publish/publish.d.ts +23 -0
- package/dist/publish/publish.js +128 -0
- package/dist/publish/sync-definition.d.ts +4 -0
- package/dist/publish/sync-definition.js +48 -0
- package/dist/skill.d.ts +10 -0
- package/dist/skill.js +50 -0
- package/dist/templates/compose-import/compose.yaml +11 -0
- package/dist/templates/compose-import/synology-app.yaml +15 -0
- package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/dist/templates/hello-files/compose.yaml +8 -0
- package/dist/templates/hello-files/synology-app.yaml +13 -0
- package/dist/templates/hello-web/compose.yaml +7 -0
- package/dist/templates/hello-web/html/app.js +23 -0
- package/dist/templates/hello-web/html/index.html +15 -0
- package/dist/templates/hello-web/html/sdk.js +68 -0
- package/dist/templates/hello-web/synology-app.yaml +13 -0
- package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/dist/templates/hello-web-page/compose.yaml +7 -0
- package/dist/templates/hello-web-page/html/app.js +23 -0
- package/dist/templates/hello-web-page/html/index.html +15 -0
- package/dist/templates/hello-web-page/html/sdk.js +68 -0
- package/dist/templates/hello-web-page/synology-app.yaml +13 -0
- package/dist/test-command.d.ts +13 -0
- package/dist/test-command.js +26 -0
- package/dist/test-host.d.ts +13 -0
- package/dist/test-host.js +24 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +103 -0
- package/package.json +38 -0
- package/schema/synology-app.schema.json +137 -0
- package/skills/SKILL.md +51 -0
- package/skills/reference/create.md +34 -0
- package/skills/reference/deploy.md +41 -0
- package/skills/reference/diagnose.md +42 -0
- package/skills/reference/files.md +37 -0
- package/skills/reference/identity.md +31 -0
- package/skills/reference/wrap.md +35 -0
- package/templates/compose-import/compose.yaml +11 -0
- package/templates/compose-import/synology-app.yaml +15 -0
- package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/templates/hello-files/compose.yaml +8 -0
- package/templates/hello-files/synology-app.yaml +13 -0
- package/templates/hello-web/compose.yaml +7 -0
- package/templates/hello-web/html/app.js +23 -0
- package/templates/hello-web/html/index.html +15 -0
- package/templates/hello-web/html/sdk.js +68 -0
- package/templates/hello-web/synology-app.yaml +13 -0
- package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/templates/hello-web-page/compose.yaml +7 -0
- package/templates/hello-web-page/html/app.js +23 -0
- package/templates/hello-web-page/html/index.html +15 -0
- package/templates/hello-web-page/html/sdk.js +68 -0
- package/templates/hello-web-page/synology-app.yaml +13 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { composeSpan, makeError } from "../errors.js";
|
|
5
|
+
import { undeclaredPrivilegeFindings } from "./privilege.js";
|
|
6
|
+
const DOCKER_SOCKET_PATHS = [
|
|
7
|
+
"/var/run/docker.sock",
|
|
8
|
+
"/var/run/docker.sock/",
|
|
9
|
+
"/var/run/containers/container-manager.sock",
|
|
10
|
+
"/var/run/containers/container-manager.sock/",
|
|
11
|
+
];
|
|
12
|
+
export function loadCompose(composePath) {
|
|
13
|
+
if (!existsSync(composePath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const source = readFileSync(composePath, "utf8");
|
|
17
|
+
const doc = parseDocument(source);
|
|
18
|
+
const compose = (doc.toJSON() ?? {});
|
|
19
|
+
return { compose, source };
|
|
20
|
+
}
|
|
21
|
+
export function listServiceNames(compose) {
|
|
22
|
+
if (!compose.services || typeof compose.services !== "object") {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return Object.keys(compose.services);
|
|
26
|
+
}
|
|
27
|
+
export function extractPublishedTcpPorts(service) {
|
|
28
|
+
const ports = service.ports;
|
|
29
|
+
if (!ports) {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
const values = Array.isArray(ports) ? ports : [ports];
|
|
33
|
+
const published = [];
|
|
34
|
+
for (const entry of values) {
|
|
35
|
+
const hostPort = parsePortEntry(entry);
|
|
36
|
+
if (hostPort !== null) {
|
|
37
|
+
published.push(hostPort);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return published;
|
|
41
|
+
}
|
|
42
|
+
function parsePortEntry(entry) {
|
|
43
|
+
if (typeof entry === "number") {
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
if (typeof entry === "string") {
|
|
47
|
+
const trimmed = entry.trim();
|
|
48
|
+
if (!trimmed || trimmed.startsWith("/")) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const hostPart = trimmed.split(":")[0]?.replace(/^\[/, "").replace(/\]$/, "");
|
|
52
|
+
const parsed = Number(hostPart);
|
|
53
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
54
|
+
}
|
|
55
|
+
if (entry && typeof entry === "object") {
|
|
56
|
+
const record = entry;
|
|
57
|
+
if (typeof record.published === "number") {
|
|
58
|
+
return record.published;
|
|
59
|
+
}
|
|
60
|
+
if (typeof record.target === "number" && record.published === undefined) {
|
|
61
|
+
return record.target;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
export function collectPublishedTcpPorts(compose) {
|
|
67
|
+
const result = new Map();
|
|
68
|
+
if (!compose.services) {
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
72
|
+
for (const port of extractPublishedTcpPorts(service)) {
|
|
73
|
+
const services = result.get(port) ?? [];
|
|
74
|
+
services.push(serviceName);
|
|
75
|
+
result.set(port, services);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
export function collectComposeInterpolations(compose) {
|
|
81
|
+
const names = new Set();
|
|
82
|
+
walkForInterpolation(compose, names);
|
|
83
|
+
return names;
|
|
84
|
+
}
|
|
85
|
+
function walkForInterpolation(value, names) {
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
for (const match of value.matchAll(/\$\{([A-Z0-9_]+)\}/g)) {
|
|
88
|
+
names.add(match[1]);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
for (const item of value) {
|
|
94
|
+
walkForInterpolation(item, names);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (value && typeof value === "object") {
|
|
99
|
+
for (const child of Object.values(value)) {
|
|
100
|
+
walkForInterpolation(child, names);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function validateComposeSecurity(compose, composeFile, options) {
|
|
105
|
+
const errors = [];
|
|
106
|
+
if (!compose.services) {
|
|
107
|
+
return errors;
|
|
108
|
+
}
|
|
109
|
+
const undeclaredSeverity = options.profile === "listing" ? "error" : "warning";
|
|
110
|
+
for (const finding of undeclaredPrivilegeFindings(compose, options.declaredPrivilege)) {
|
|
111
|
+
errors.push(makeError(finding.code, composeSpan(composeFile, 1, 1, finding.path), finding.message, finding.suggestion, undeclaredSeverity));
|
|
112
|
+
}
|
|
113
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
114
|
+
const basePath = `services.${serviceName}`;
|
|
115
|
+
errors.push(...validateSocketMounts(service, composeFile, basePath));
|
|
116
|
+
const dependsOn = normalizeDependsOn(service.depends_on);
|
|
117
|
+
for (const dependency of dependsOn) {
|
|
118
|
+
if (!compose.services?.[dependency]) {
|
|
119
|
+
errors.push(makeError("COMPOSE_SERVICE_NOT_FOUND", composeSpan(composeFile, 1, 1, `${basePath}.depends_on`), `Compose 引用了不存在的 service「${dependency}」。`, "請修正 depends_on,或補上對應的 service 定義。"));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return errors;
|
|
124
|
+
}
|
|
125
|
+
function normalizeDependsOn(value) {
|
|
126
|
+
if (!value) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
return value.map(String);
|
|
131
|
+
}
|
|
132
|
+
if (typeof value === "object") {
|
|
133
|
+
return Object.keys(value);
|
|
134
|
+
}
|
|
135
|
+
return [String(value)];
|
|
136
|
+
}
|
|
137
|
+
function validateSocketMounts(service, composeFile, basePath) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
const volumes = normalizeVolumes(service.volumes);
|
|
140
|
+
for (const mount of volumes) {
|
|
141
|
+
const source = mount.split(":")[0]?.trim() ?? "";
|
|
142
|
+
if (isForbiddenSocketMount(source)) {
|
|
143
|
+
errors.push(makeError("COMPOSE_SOCKET_MOUNT_FORBIDDEN", composeSpan(composeFile, 1, 1, `${basePath}.volumes`), "Managed Profile 不允許掛載 Docker 或 Container Manager socket。", "請移除 docker.sock/container-manager socket 的 volume mount。"));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return errors;
|
|
147
|
+
}
|
|
148
|
+
function normalizeVolumes(volumes) {
|
|
149
|
+
if (!volumes) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const entries = Array.isArray(volumes) ? volumes : [volumes];
|
|
153
|
+
return entries
|
|
154
|
+
.map((entry) => {
|
|
155
|
+
if (typeof entry === "string") {
|
|
156
|
+
return entry;
|
|
157
|
+
}
|
|
158
|
+
if (entry && typeof entry === "object" && "source" in entry) {
|
|
159
|
+
return String(entry.source);
|
|
160
|
+
}
|
|
161
|
+
return "";
|
|
162
|
+
})
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
function isForbiddenSocketMount(source) {
|
|
166
|
+
const normalized = source.replace(/\/+$/, "");
|
|
167
|
+
return DOCKER_SOCKET_PATHS.some((path) => normalized === path.replace(/\/+$/, "") || normalized.endsWith("/docker.sock"));
|
|
168
|
+
}
|
|
169
|
+
const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
|
|
170
|
+
const INTERPOLATED_SOURCE = /^\$\{([A-Z0-9_]+)\}(.*)$/;
|
|
171
|
+
const VALID_SUFFIX_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
172
|
+
function collectNamedVolumeNames(compose) {
|
|
173
|
+
const volumes = compose.volumes;
|
|
174
|
+
if (!volumes || typeof volumes !== "object" || Array.isArray(volumes)) {
|
|
175
|
+
return new Set();
|
|
176
|
+
}
|
|
177
|
+
return new Set(Object.keys(volumes));
|
|
178
|
+
}
|
|
179
|
+
export function collectVolumeSourceInterpolations(compose) {
|
|
180
|
+
const names = new Set();
|
|
181
|
+
for (const mount of listVolumeMounts(compose)) {
|
|
182
|
+
const match = mount.source.match(INTERPOLATED_SOURCE);
|
|
183
|
+
if (match) {
|
|
184
|
+
names.add(match[1]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return names;
|
|
188
|
+
}
|
|
189
|
+
export function validateComposeHostBinds(compose, composeFile, context) {
|
|
190
|
+
const errors = [];
|
|
191
|
+
if (!compose.services) {
|
|
192
|
+
return errors;
|
|
193
|
+
}
|
|
194
|
+
const namedVolumes = collectNamedVolumeNames(compose);
|
|
195
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
196
|
+
const spanPath = `services.${serviceName}.volumes`;
|
|
197
|
+
for (const mount of parseVolumeMounts(service.volumes)) {
|
|
198
|
+
const error = classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath);
|
|
199
|
+
if (error) {
|
|
200
|
+
errors.push(error);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return errors;
|
|
205
|
+
}
|
|
206
|
+
function listVolumeMounts(compose) {
|
|
207
|
+
if (!compose.services) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const mounts = [];
|
|
211
|
+
for (const service of Object.values(compose.services)) {
|
|
212
|
+
mounts.push(...parseVolumeMounts(service.volumes));
|
|
213
|
+
}
|
|
214
|
+
return mounts;
|
|
215
|
+
}
|
|
216
|
+
function parseVolumeMounts(volumes) {
|
|
217
|
+
if (!volumes) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const entries = Array.isArray(volumes) ? volumes : [volumes];
|
|
221
|
+
const mounts = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (typeof entry === "string") {
|
|
224
|
+
const source = parseShortVolumeSource(entry);
|
|
225
|
+
if (source !== null) {
|
|
226
|
+
mounts.push({ source });
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (entry && typeof entry === "object") {
|
|
231
|
+
const record = entry;
|
|
232
|
+
if (typeof record.source !== "string" || !record.source.trim()) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
mounts.push({
|
|
236
|
+
source: record.source.trim(),
|
|
237
|
+
type: typeof record.type === "string" ? record.type : undefined,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return mounts;
|
|
242
|
+
}
|
|
243
|
+
function parseShortVolumeSource(entry) {
|
|
244
|
+
const trimmed = entry.trim();
|
|
245
|
+
if (!trimmed) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const colon = trimmed.indexOf(":");
|
|
249
|
+
if (colon <= 0) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return trimmed.slice(0, colon).trim();
|
|
253
|
+
}
|
|
254
|
+
function classifyVolumeSource(mount, namedVolumes, context, composeFile, spanPath) {
|
|
255
|
+
const source = mount.source;
|
|
256
|
+
if (!source || isForbiddenSocketMount(source)) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
const span = composeSpan(composeFile, 1, 1, spanPath);
|
|
260
|
+
if (source.includes("$")) {
|
|
261
|
+
const match = source.match(INTERPOLATED_SOURCE);
|
|
262
|
+
const varName = match?.[1];
|
|
263
|
+
const suffix = match?.[2];
|
|
264
|
+
if (!varName || suffix === undefined || !isValidatedBindSuffix(suffix)) {
|
|
265
|
+
return invalidBindSuffix(span);
|
|
266
|
+
}
|
|
267
|
+
if (PLATFORM_BIND_VARS.has(varName) || context.sharedFolderParams.has(varName)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (context.installParamNames.has(varName)) {
|
|
271
|
+
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, `參數「${varName}」不能作為 host bind source。`, "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR} 或 type 為 shared_folder 的 install 參數。");
|
|
272
|
+
}
|
|
273
|
+
return makeError("COMPOSE_BIND_SOURCE_UNDECLARED", span, `volume source 引用了未宣告的參數「${varName}」。`, `請在 synology-app.yaml 的 install 宣告 ${varName},且 type 為 shared_folder。`);
|
|
274
|
+
}
|
|
275
|
+
if (isLiteralHostPath(source)) {
|
|
276
|
+
return makeError("COMPOSE_HOST_BIND_FORBIDDEN", span, "禁止將 /volume*、絕對路徑或開發者機器路徑寫成 Compose volume source。", "請改用 install 參數 type: shared_folder,並以 ${參數名} 作為 bind source。");
|
|
277
|
+
}
|
|
278
|
+
if (mount.type === "tmpfs") {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (mount.type === "bind") {
|
|
282
|
+
return makeError("COMPOSE_BIND_SOURCE_FORBIDDEN", span, "禁止使用 type: bind 掛載非平台允許的 host source。", "請改用 ${SYNOPKG_PKGVAR}、${SYNO_APP_SECRETS_DIR} 或契約中的 ${shared_folder 參數}。");
|
|
283
|
+
}
|
|
284
|
+
if (!namedVolumes.has(source)) {
|
|
285
|
+
return makeError("COMPOSE_NAMED_VOLUME_UNDECLARED", span, `短語法 named volume「${source}」未在頂層 volumes 宣告,無法判斷它是 Docker volume 還是 Share 名。`, `請在頂層 volumes 宣告 ${source},或改用 \${參數名} 作為 shared_folder bind source。`);
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function invalidBindSuffix(span) {
|
|
290
|
+
return makeError("COMPOSE_BIND_SUFFIX_INVALID", span, "volume source 的路徑後綴不合法。", "後綴只能是 / 加上一段或多段 [A-Za-z0-9._-],不可含 ..、${ 或絕對路徑。");
|
|
291
|
+
}
|
|
292
|
+
function isValidatedBindSuffix(suffix) {
|
|
293
|
+
if (suffix === "") {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (suffix.includes("${") || suffix.includes(":")) {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
if (!/^(\/[A-Za-z0-9._-]+)+$/.test(suffix)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
return suffix
|
|
303
|
+
.split("/")
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.every((segment) => VALID_SUFFIX_SEGMENT.test(segment) && segment !== "." && segment !== "..");
|
|
306
|
+
}
|
|
307
|
+
function isLiteralHostPath(source) {
|
|
308
|
+
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
309
|
+
}
|
|
310
|
+
const NUMERIC_USER = /^(0|[1-9][0-9]*)(:(0|[1-9][0-9]*))?$/;
|
|
311
|
+
const ROOT_USERS = new Set(["0", "0:0", "root"]);
|
|
312
|
+
export function validateComposeRunAs(compose, composeFile, context) {
|
|
313
|
+
const errors = [];
|
|
314
|
+
if (!compose.services) {
|
|
315
|
+
return errors;
|
|
316
|
+
}
|
|
317
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
318
|
+
const raw = service.user;
|
|
319
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const user = raw.trim();
|
|
323
|
+
const span = composeSpan(composeFile, 1, 1, `services.${serviceName}.user`);
|
|
324
|
+
if (user.includes("${")) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (context.hasSharedFolder && ROOT_USERS.has(user)) {
|
|
328
|
+
errors.push(makeError("COMPOSE_USER_ROOT_FORBIDDEN", span, "有 Shared Folder 時容器不可用 uid 0/root 寫入 Share。", "請宣告 dsm_user,並以 ${NAME_UID}:${NAME_GID} 當 Compose user。", "warning"));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (NUMERIC_USER.test(user)) {
|
|
332
|
+
errors.push(makeError("COMPOSE_USER_NUMERIC_FORBIDDEN", span, "契約不准寫死數字 uid。", "請改用 install dsm_user 參數的 ${NAME_UID}:${NAME_GID}。"));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return errors;
|
|
336
|
+
}
|
|
337
|
+
export function validateAdminPortMapping(adminport, compose, composeFile) {
|
|
338
|
+
const published = collectPublishedTcpPorts(compose);
|
|
339
|
+
const services = published.get(adminport) ?? [];
|
|
340
|
+
if (services.length !== 1) {
|
|
341
|
+
return [
|
|
342
|
+
makeError("CONTRACT_ADMINPORT_UNMAPPED", composeSpan(composeFile, 1, 1, `adminport:${adminport}`), `adminport ${adminport} 必須對應 Compose 中恰好一個 published TCP port。`, "請在 compose.yaml 發佈與 adminport 相同的 host port,並確認只有一個 service 使用它。"),
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface ComposeEnvironment {
|
|
2
|
+
SYNOPKG_PKGVAR: string;
|
|
3
|
+
SYNO_APP_SECRETS_DIR: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function prepareComposeEnvironment(projectRoot: string): ComposeEnvironment;
|
|
6
|
+
export declare function materializeComposePaths(projectRoot: string, composePath: string): ComposeEnvironment;
|
|
7
|
+
export declare function composeEnvDirsExist(projectRoot: string): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function prepareComposeEnvironment(projectRoot) {
|
|
4
|
+
const varDir = join(projectRoot, ".synopkgland", "var");
|
|
5
|
+
const secretsDir = join(projectRoot, ".synopkgland", "secrets");
|
|
6
|
+
mkdirSync(varDir, { recursive: true });
|
|
7
|
+
mkdirSync(secretsDir, { recursive: true });
|
|
8
|
+
return {
|
|
9
|
+
SYNOPKG_PKGVAR: varDir,
|
|
10
|
+
SYNO_APP_SECRETS_DIR: secretsDir,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function materializeComposePaths(projectRoot, composePath) {
|
|
14
|
+
const env = prepareComposeEnvironment(projectRoot);
|
|
15
|
+
if (!existsSync(composePath)) {
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
const source = readFileSync(composePath, "utf8");
|
|
19
|
+
for (const match of source.matchAll(/\$\{SYNOPKG_PKGVAR\}([^:\s"'`]+)/g)) {
|
|
20
|
+
const subpath = match[1].replace(/^\/+/, "");
|
|
21
|
+
if (subpath) {
|
|
22
|
+
mkdirSync(join(env.SYNOPKG_PKGVAR, subpath), { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const match of source.matchAll(/\$\{SYNO_APP_SECRETS_DIR\}([^:\s"'`]+)/g)) {
|
|
26
|
+
const subpath = match[1].replace(/^\/+/, "");
|
|
27
|
+
if (subpath) {
|
|
28
|
+
mkdirSync(join(env.SYNO_APP_SECRETS_DIR, subpath), { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return env;
|
|
32
|
+
}
|
|
33
|
+
export function composeEnvDirsExist(projectRoot) {
|
|
34
|
+
return (existsSync(join(projectRoot, ".synopkgland", "var")) &&
|
|
35
|
+
existsSync(join(projectRoot, ".synopkgland", "secrets")));
|
|
36
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AppContract, ComposeDocument } from "../types.js";
|
|
2
|
+
export declare const PRIVILEGE_FLAGS: readonly ["privileged", "host_network", "host_pid", "host_ipc", "devices", "cap_add"];
|
|
3
|
+
export type PrivilegeFlag = (typeof PRIVILEGE_FLAGS)[number];
|
|
4
|
+
export interface PrivilegeFinding {
|
|
5
|
+
flag: PrivilegeFlag;
|
|
6
|
+
code: string;
|
|
7
|
+
serviceName: string;
|
|
8
|
+
path: string;
|
|
9
|
+
message: string;
|
|
10
|
+
suggestion: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function declaredPrivilegeFlags(contract: AppContract): Set<PrivilegeFlag>;
|
|
13
|
+
export declare function scanComposePrivilege(compose: ComposeDocument): PrivilegeFinding[];
|
|
14
|
+
export declare function undeclaredPrivilegeFindings(compose: ComposeDocument, declared: ReadonlySet<PrivilegeFlag>): PrivilegeFinding[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export const PRIVILEGE_FLAGS = [
|
|
2
|
+
"privileged",
|
|
3
|
+
"host_network",
|
|
4
|
+
"host_pid",
|
|
5
|
+
"host_ipc",
|
|
6
|
+
"devices",
|
|
7
|
+
"cap_add",
|
|
8
|
+
];
|
|
9
|
+
const FLAG_ERRORS = {
|
|
10
|
+
privileged: {
|
|
11
|
+
code: "COMPOSE_PRIVILEGED_FORBIDDEN",
|
|
12
|
+
message: "Compose 使用 privileged 容器,契約未宣告 privilege.privileged。",
|
|
13
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.privileged: true,或移除 privileged: true。",
|
|
14
|
+
},
|
|
15
|
+
host_network: {
|
|
16
|
+
code: "COMPOSE_HOST_NETWORK_FORBIDDEN",
|
|
17
|
+
message: "Compose 使用 host network,契約未宣告 privilege.host_network。",
|
|
18
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.host_network: true,或改用受控網路模式。",
|
|
19
|
+
},
|
|
20
|
+
host_pid: {
|
|
21
|
+
code: "COMPOSE_HOST_PID_FORBIDDEN",
|
|
22
|
+
message: "Compose 使用 host PID,契約未宣告 privilege.host_pid。",
|
|
23
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.host_pid: true,或移除 pid: host。",
|
|
24
|
+
},
|
|
25
|
+
host_ipc: {
|
|
26
|
+
code: "COMPOSE_HOST_IPC_FORBIDDEN",
|
|
27
|
+
message: "Compose 使用 host IPC,契約未宣告 privilege.host_ipc。",
|
|
28
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.host_ipc: true,或移除 ipc: host。",
|
|
29
|
+
},
|
|
30
|
+
devices: {
|
|
31
|
+
code: "COMPOSE_DEVICES_FORBIDDEN",
|
|
32
|
+
message: "Compose 掛載 devices,契約未宣告 privilege.devices。",
|
|
33
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.devices: true,或移除 devices。",
|
|
34
|
+
},
|
|
35
|
+
cap_add: {
|
|
36
|
+
code: "COMPOSE_CAP_ADD_FORBIDDEN",
|
|
37
|
+
message: "Compose 使用額外 cap_add,契約未宣告 privilege.cap_add。",
|
|
38
|
+
suggestion: "請在 synology-app.yaml 加上 privilege.cap_add: true,或移除 cap_add。",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
export function declaredPrivilegeFlags(contract) {
|
|
42
|
+
const raw = contract.privilege;
|
|
43
|
+
const flags = new Set();
|
|
44
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
45
|
+
return flags;
|
|
46
|
+
}
|
|
47
|
+
const record = raw;
|
|
48
|
+
for (const flag of PRIVILEGE_FLAGS) {
|
|
49
|
+
if (record[flag] === true) {
|
|
50
|
+
flags.add(flag);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return flags;
|
|
54
|
+
}
|
|
55
|
+
export function scanComposePrivilege(compose) {
|
|
56
|
+
const findings = [];
|
|
57
|
+
if (!compose.services) {
|
|
58
|
+
return findings;
|
|
59
|
+
}
|
|
60
|
+
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
61
|
+
const basePath = `services.${serviceName}`;
|
|
62
|
+
const add = (flag, path) => {
|
|
63
|
+
const spec = FLAG_ERRORS[flag];
|
|
64
|
+
findings.push({
|
|
65
|
+
flag,
|
|
66
|
+
code: spec.code,
|
|
67
|
+
serviceName,
|
|
68
|
+
path,
|
|
69
|
+
message: spec.message,
|
|
70
|
+
suggestion: spec.suggestion,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
if (service.privileged === true) {
|
|
74
|
+
add("privileged", `${basePath}.privileged`);
|
|
75
|
+
}
|
|
76
|
+
if (service.network_mode === "host") {
|
|
77
|
+
add("host_network", `${basePath}.network_mode`);
|
|
78
|
+
}
|
|
79
|
+
if (service.pid === "host") {
|
|
80
|
+
add("host_pid", `${basePath}.pid`);
|
|
81
|
+
}
|
|
82
|
+
if (service.ipc === "host") {
|
|
83
|
+
add("host_ipc", `${basePath}.ipc`);
|
|
84
|
+
}
|
|
85
|
+
if (hasEntries(service.devices)) {
|
|
86
|
+
add("devices", `${basePath}.devices`);
|
|
87
|
+
}
|
|
88
|
+
if (hasEntries(service.cap_add)) {
|
|
89
|
+
add("cap_add", `${basePath}.cap_add`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
export function undeclaredPrivilegeFindings(compose, declared) {
|
|
95
|
+
return scanComposePrivilege(compose).filter((item) => !declared.has(item.flag));
|
|
96
|
+
}
|
|
97
|
+
function hasEntries(value) {
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
return value.length > 0;
|
|
100
|
+
}
|
|
101
|
+
return typeof value === "string" && value.trim() !== "";
|
|
102
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ComposeEnvironment } from "./env.js";
|
|
2
|
+
export interface ComposeContainerState {
|
|
3
|
+
Health?: string;
|
|
4
|
+
State?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ComposeRunner {
|
|
7
|
+
up(projectRoot: string): Promise<void>;
|
|
8
|
+
waitReady(projectRoot: string): Promise<void>;
|
|
9
|
+
down?(projectRoot: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface ComposeInvocation {
|
|
12
|
+
command: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
env?: ComposeEnvironment;
|
|
15
|
+
}
|
|
16
|
+
export interface ComposePollOptions {
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
intervalMs?: number;
|
|
19
|
+
sleep?: (ms: number) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
export declare function evaluateComposeReady(containers: ComposeContainerState[]): {
|
|
22
|
+
ready: boolean;
|
|
23
|
+
hasHealthcheck: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function pollUntilComposeReady(inspect: () => Promise<ComposeContainerState[]>, options?: ComposePollOptions): Promise<void>;
|
|
26
|
+
export declare class RecordingComposeRunner implements ComposeRunner {
|
|
27
|
+
readonly invocations: ComposeInvocation[];
|
|
28
|
+
up(_projectRoot: string): Promise<void>;
|
|
29
|
+
waitReady(_projectRoot: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export declare function createDockerComposeRunner(options?: ComposePollOptions): ComposeRunner;
|
|
32
|
+
export declare function isDockerAvailable(): Promise<boolean>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { materializeComposePaths } from "./env.js";
|
|
4
|
+
import { COMPOSE_FILENAME } from "../discovery.js";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export function evaluateComposeReady(containers) {
|
|
8
|
+
if (containers.length === 0) {
|
|
9
|
+
return { ready: false, hasHealthcheck: false };
|
|
10
|
+
}
|
|
11
|
+
const hasHealthcheck = containers.some((c) => c.Health && c.Health !== "");
|
|
12
|
+
if (hasHealthcheck) {
|
|
13
|
+
const ready = containers.every((c) => !c.Health || c.Health === "healthy");
|
|
14
|
+
return { ready, hasHealthcheck: true };
|
|
15
|
+
}
|
|
16
|
+
const ready = containers.every((c) => c.State === "running");
|
|
17
|
+
return { ready, hasHealthcheck: false };
|
|
18
|
+
}
|
|
19
|
+
export async function pollUntilComposeReady(inspect, options = {}) {
|
|
20
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
21
|
+
const intervalMs = options.intervalMs ?? 1_000;
|
|
22
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
23
|
+
const deadline = Date.now() + timeoutMs;
|
|
24
|
+
while (Date.now() < deadline) {
|
|
25
|
+
const containers = await inspect();
|
|
26
|
+
const { ready } = evaluateComposeReady(containers);
|
|
27
|
+
if (ready) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await sleep(intervalMs);
|
|
31
|
+
}
|
|
32
|
+
const containers = await inspect();
|
|
33
|
+
const { hasHealthcheck } = evaluateComposeReady(containers);
|
|
34
|
+
throw new Error(hasHealthcheck ? "COMPOSE_NOT_HEALTHY" : "COMPOSE_NOT_RUNNING");
|
|
35
|
+
}
|
|
36
|
+
export class RecordingComposeRunner {
|
|
37
|
+
invocations = [];
|
|
38
|
+
async up(_projectRoot) {
|
|
39
|
+
this.invocations.push({ command: "compose", args: ["up", "-d"] });
|
|
40
|
+
}
|
|
41
|
+
async waitReady(_projectRoot) {
|
|
42
|
+
this.invocations.push({ command: "compose", args: ["ps", "--format", "json"] });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function runDockerCompose(projectRoot, args, env) {
|
|
46
|
+
const { stdout } = await execFileAsync("docker", ["compose", ...args], {
|
|
47
|
+
cwd: projectRoot,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
50
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
51
|
+
});
|
|
52
|
+
return stdout;
|
|
53
|
+
}
|
|
54
|
+
async function inspectComposeContainers(projectRoot) {
|
|
55
|
+
const stdout = await runDockerCompose(projectRoot, ["ps", "--format", "json"]);
|
|
56
|
+
const lines = stdout
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
return lines.map((line) => JSON.parse(line));
|
|
61
|
+
}
|
|
62
|
+
export function createDockerComposeRunner(options = {}) {
|
|
63
|
+
return {
|
|
64
|
+
async up(projectRoot) {
|
|
65
|
+
const env = materializeComposePaths(projectRoot, join(projectRoot, COMPOSE_FILENAME));
|
|
66
|
+
await runDockerCompose(projectRoot, ["up", "-d"], env);
|
|
67
|
+
},
|
|
68
|
+
async waitReady(projectRoot) {
|
|
69
|
+
await pollUntilComposeReady(() => inspectComposeContainers(projectRoot), options);
|
|
70
|
+
},
|
|
71
|
+
async down(projectRoot) {
|
|
72
|
+
await runDockerCompose(projectRoot, ["down", "--remove-orphans"]);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export async function isDockerAvailable() {
|
|
77
|
+
try {
|
|
78
|
+
await execFileAsync("docker", ["info"], { encoding: "utf8" });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Document } from "yaml";
|
|
2
|
+
import type { AppContract, InstallParameter } from "../types.js";
|
|
3
|
+
export interface ParsedContract {
|
|
4
|
+
contract: AppContract;
|
|
5
|
+
doc: Document;
|
|
6
|
+
}
|
|
7
|
+
export declare function loadContract(contractPath: string): ParsedContract;
|
|
8
|
+
export declare function getScalarPosition(doc: Document, keyPath: string[]): {
|
|
9
|
+
line: number;
|
|
10
|
+
column: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export declare function getValuePosition(doc: Document, keyPath: string[]): {
|
|
13
|
+
line: number;
|
|
14
|
+
column: number;
|
|
15
|
+
} | null;
|
|
16
|
+
export declare function collectInstallParameters(contract: AppContract): Array<{
|
|
17
|
+
name: string;
|
|
18
|
+
param: InstallParameter;
|
|
19
|
+
}>;
|
|
20
|
+
export declare function isDeveloperAuthoredString(value: unknown): value is string;
|
|
21
|
+
export declare function stringViolations(value: string): {
|
|
22
|
+
hasControlChars: boolean;
|
|
23
|
+
hasInterpolation: boolean;
|
|
24
|
+
tooLong: boolean;
|
|
25
|
+
};
|