@synopackageland/cli 0.3.6 → 0.3.8
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/build-spk/generator.js +162 -9
- package/dist/build-spk/resource-worker.js +16 -31
- package/dist/compose/analyze.d.ts +1 -0
- package/dist/compose/analyze.js +16 -7
- package/dist/contract/posix-all-shares.js +2 -2
- package/dist/contract/schema-validate.js +1 -26
- package/dist/contract/validate.js +0 -10
- package/dist/deploy/deploy.js +30 -2
- package/dist/deploy/param-values.js +2 -1
- package/dist/doctor.js +54 -0
- package/dist/error-registry.js +8 -3
- package/dist/host/dsm-client.js +56 -2
- package/dist/host/fake-nas.d.ts +4 -1
- package/dist/host/fake-nas.js +22 -1
- package/dist/host/share-lookup.d.ts +2 -0
- package/dist/host/share-lookup.js +17 -0
- package/dist/host/types.d.ts +17 -0
- package/dist/validate.js +7 -2
- package/package.json +1 -1
- package/schema/synology-app.schema.json +1 -1
- package/skills/reference/create.md +4 -0
- package/skills/reference/deploy.md +1 -1
|
@@ -20,9 +20,23 @@ import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuni
|
|
|
20
20
|
import { renderHeadlessResourceMarker, renderResourceConfig, renderResourceWorkerStartStopStatus, } from "./resource-worker.js";
|
|
21
21
|
const FILE_MODE = 0o644;
|
|
22
22
|
const SCRIPT_MODE = 0o755;
|
|
23
|
+
function hasInterpolation(value) {
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
return /\$\{([^}]+)\}/.test(value);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return value.some(hasInterpolation);
|
|
29
|
+
}
|
|
30
|
+
if (value && typeof value === "object") {
|
|
31
|
+
return Object.values(value).some(hasInterpolation);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
23
35
|
const INSTALL_UIFILE = "install_uifile";
|
|
24
36
|
const UPGRADE_UIFILE = "upgrade_uifile";
|
|
25
37
|
const UNINSTALL_UIFILE = "uninstall_uifile";
|
|
38
|
+
const HEADLESS_PROBE_VOLUME = "./.synopkgland:/run/synopkgland:ro";
|
|
39
|
+
const HEADLESS_PROBE_README = "Reserved bind target for package-user headless status probing.\n";
|
|
26
40
|
export const EMBEDDED_ATTESTATION_RELATIVE_PATH = ".synopkgland/attestation.jwt";
|
|
27
41
|
const SCRIPT_NAMES = [
|
|
28
42
|
"preinst",
|
|
@@ -171,11 +185,12 @@ export async function buildSpk(options) {
|
|
|
171
185
|
const resourceProjectDir = join(staging, "package", "compose");
|
|
172
186
|
mkdirSync(resourceProjectDir, { recursive: true });
|
|
173
187
|
const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
|
|
174
|
-
|
|
188
|
+
let composeContent;
|
|
189
|
+
if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
|
|
175
190
|
const rawYaml = readFileSync(options.project.composePath, "utf8");
|
|
176
191
|
const doc = parseDocument(rawYaml);
|
|
177
192
|
const services = doc.get("services");
|
|
178
|
-
if (services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
193
|
+
if (dsmUserParam && services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
179
194
|
const serviceNames = Object.keys(services.toJSON());
|
|
180
195
|
for (const sName of serviceNames) {
|
|
181
196
|
const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
|
|
@@ -183,15 +198,49 @@ export async function buildSpk(options) {
|
|
|
183
198
|
doc.setIn(["services", sName, "user"], userVal);
|
|
184
199
|
}
|
|
185
200
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
201
|
+
if (hasOidcCompat(contract) && services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
202
|
+
const rawServices = services.toJSON();
|
|
203
|
+
for (const [sName, sValue] of Object.entries(rawServices)) {
|
|
204
|
+
if (sValue && typeof sValue === "object" && hasInterpolation(sValue)) {
|
|
205
|
+
const str = JSON.stringify(sValue);
|
|
206
|
+
if (str.includes("SYNOPKGLAND_OIDC_ISSUER")) {
|
|
207
|
+
const serviceDoc = doc.getIn(["services", sName]);
|
|
208
|
+
const existingExtraHosts = serviceDoc?.get?.("extra_hosts");
|
|
209
|
+
const hostGatewayMapping = "${SYNOPKGLAND_OIDC_ISSUER_HOST}:host-gateway";
|
|
210
|
+
if (!existingExtraHosts) {
|
|
211
|
+
doc.setIn(["services", sName, "extra_hosts"], [hostGatewayMapping]);
|
|
212
|
+
}
|
|
213
|
+
else if (Array.isArray(existingExtraHosts.toJSON?.())) {
|
|
214
|
+
const arr = existingExtraHosts.toJSON();
|
|
215
|
+
if (!arr.includes(hostGatewayMapping)) {
|
|
216
|
+
doc.setIn(["services", sName, "extra_hosts"], [...arr, hostGatewayMapping]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (rwEligibility?.headless === true) {
|
|
224
|
+
injectHeadlessProbeVolume(doc);
|
|
225
|
+
}
|
|
226
|
+
composeContent = doc.toString();
|
|
227
|
+
if (dsmUserParam) {
|
|
228
|
+
const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
|
|
229
|
+
composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
|
|
230
|
+
}
|
|
190
231
|
}
|
|
191
232
|
else {
|
|
192
|
-
|
|
233
|
+
composeContent = readFileSync(options.project.composePath, "utf8");
|
|
193
234
|
}
|
|
235
|
+
composeContent = injectPosixAllSharesMarkers(composeContent, contract);
|
|
236
|
+
writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
|
|
194
237
|
chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
|
|
238
|
+
if (rwEligibility?.headless === true) {
|
|
239
|
+
const probePayloadDir = join(resourceProjectDir, ".synopkgland");
|
|
240
|
+
mkdirSync(probePayloadDir, { recursive: true });
|
|
241
|
+
writeFileSync(join(probePayloadDir, "README"), HEADLESS_PROBE_README, "utf8");
|
|
242
|
+
chmodSync(join(probePayloadDir, "README"), FILE_MODE);
|
|
243
|
+
}
|
|
195
244
|
const routeMarkerDir = join(staging, "package", ".synopkgland");
|
|
196
245
|
mkdirSync(routeMarkerDir, { recursive: true });
|
|
197
246
|
const routeMarker = join(routeMarkerDir, "route");
|
|
@@ -307,6 +356,7 @@ function renderPrivilege() {
|
|
|
307
356
|
}
|
|
308
357
|
const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
309
358
|
const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
359
|
+
const POSIX_ALL_SHARES_MAX_WARNING = "掛載這台 NAS 目前列出的每一個共用資料夾。容器內可讀(或讀寫,依 Compose 模式)所有這些資料夾的檔案。安裝後新建的共用資料夾不會自動加入。由管理員決定是否安裝。";
|
|
310
360
|
const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
|
|
311
361
|
const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
|
|
312
362
|
const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
|
|
@@ -387,6 +437,46 @@ function composeUsesOnlyAppSpace(compose) {
|
|
|
387
437
|
const sources = volumeSources(compose);
|
|
388
438
|
return sources.every((source) => source.startsWith("${SYNOPKG_PKGVAR}") || source.startsWith("${SYNO_APP_SECRETS_DIR}"));
|
|
389
439
|
}
|
|
440
|
+
function injectHeadlessProbeVolume(doc) {
|
|
441
|
+
const compose = (doc.toJSON() ?? {});
|
|
442
|
+
const serviceName = Object.keys(compose.services ?? {})[0];
|
|
443
|
+
if (!serviceName) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const existingVolumes = compose.services?.[serviceName]?.volumes;
|
|
447
|
+
const volumes = existingVolumes == null
|
|
448
|
+
? []
|
|
449
|
+
: Array.isArray(existingVolumes)
|
|
450
|
+
? [...existingVolumes]
|
|
451
|
+
: [existingVolumes];
|
|
452
|
+
if (!volumes.some((volume) => volume === HEADLESS_PROBE_VOLUME)) {
|
|
453
|
+
volumes.push(HEADLESS_PROBE_VOLUME);
|
|
454
|
+
}
|
|
455
|
+
doc.setIn(["services", serviceName, "volumes"], volumes);
|
|
456
|
+
}
|
|
457
|
+
function injectPosixAllSharesMarkers(composeContent, contract) {
|
|
458
|
+
const names = collectInstallParameters(contract)
|
|
459
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
460
|
+
.map(({ name }) => name);
|
|
461
|
+
if (names.length === 0) {
|
|
462
|
+
return composeContent;
|
|
463
|
+
}
|
|
464
|
+
const declared = new Set(names);
|
|
465
|
+
const lines = composeContent.split("\n");
|
|
466
|
+
const output = [];
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
const match = line.match(/^(\s*)-\s*["']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}:/);
|
|
469
|
+
const name = match?.[2];
|
|
470
|
+
if (match && name && declared.has(name)) {
|
|
471
|
+
const marker = `# synopkgland:posix_all_shares ${name}`;
|
|
472
|
+
if (output.at(-1)?.trim() !== marker) {
|
|
473
|
+
output.push(`${match[1]}${marker}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
output.push(line);
|
|
477
|
+
}
|
|
478
|
+
return output.join("\n");
|
|
479
|
+
}
|
|
390
480
|
const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
|
|
391
481
|
function renderInstallWizard(contract, compose) {
|
|
392
482
|
const items = [];
|
|
@@ -493,6 +583,39 @@ function renderInstallWizard(contract, compose) {
|
|
|
493
583
|
});
|
|
494
584
|
continue;
|
|
495
585
|
}
|
|
586
|
+
if (param.type === "posix_all_shares") {
|
|
587
|
+
items.push({
|
|
588
|
+
type: "textblock",
|
|
589
|
+
desc: POSIX_ALL_SHARES_MAX_WARNING,
|
|
590
|
+
});
|
|
591
|
+
items.push({
|
|
592
|
+
type: "combobox",
|
|
593
|
+
multiSelect: true,
|
|
594
|
+
desc: `POSIX all shares: ${name}`,
|
|
595
|
+
subitems: [{
|
|
596
|
+
key: name,
|
|
597
|
+
desc: name,
|
|
598
|
+
displayField: "name",
|
|
599
|
+
valueField: "name",
|
|
600
|
+
multiSelect: true,
|
|
601
|
+
editable: false,
|
|
602
|
+
forceSelection: true,
|
|
603
|
+
autoSelect: false,
|
|
604
|
+
mode: "remote",
|
|
605
|
+
api_store: {
|
|
606
|
+
api: "SYNO.Core.Share",
|
|
607
|
+
method: "list",
|
|
608
|
+
version: 1,
|
|
609
|
+
baseParams: { limit: -1, offset: 0 },
|
|
610
|
+
root: "shares",
|
|
611
|
+
idProperty: "name",
|
|
612
|
+
fields: ["name"],
|
|
613
|
+
},
|
|
614
|
+
validator: { allowBlank: false },
|
|
615
|
+
}],
|
|
616
|
+
});
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
496
619
|
if (param.type === "dsm_user") {
|
|
497
620
|
items.push({
|
|
498
621
|
type: "combobox",
|
|
@@ -590,6 +713,7 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
590
713
|
const allParams = collectInstallParameters(contract);
|
|
591
714
|
const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
|
|
592
715
|
const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
|
|
716
|
+
const posixAllSharesParams = allParams.filter(({ param }) => param.type === "posix_all_shares");
|
|
593
717
|
const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
|
|
594
718
|
const lines = [
|
|
595
719
|
"#!/bin/sh",
|
|
@@ -652,12 +776,23 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
652
776
|
" esac",
|
|
653
777
|
"}",
|
|
654
778
|
];
|
|
655
|
-
if (sharedFolderParams.length > 0) {
|
|
779
|
+
if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
|
|
656
780
|
lines.push("resolve_shared_folder() {", " share_name=\"$1\"", " case \"$share_name\" in", " */*|*..*) die \"invalid share name $share_name\" ;;", " '') die \"share name is empty\" ;;", " esac", " mounts_file=\"${D54_MOUNTS_FILE:-/proc/mounts}\"", " if [ ! -r \"$mounts_file\" ] && [ -r \"/etc/mtab\" ]; then", " mounts_file=\"/etc/mtab\"", " fi", " if [ ! -r \"$mounts_file\" ]; then", " die \"cannot read mount points from $mounts_file\"", " fi", " vols=\"$(awk '$2 ~ /^\\/volume[0-9]+$/ {print $2}' \"$mounts_file\" | sort -u)\"", ' if [ -z "$vols" ]; then', " vols=\"$(awk '{print $2}' \"$mounts_file\" | sort -u)\"", " fi", " matched=\"\"", " count=0", " for v in $vols; do", " if [ -d \"$v/$share_name\" ]; then", " matched=\"$v/$share_name\"", " count=$((count + 1))", " fi", " done", " if [ \"$count\" -eq 0 ]; then", " die \"share $share_name not found on any volume\"", " elif [ \"$count\" -gt 1 ]; then", " die \"share $share_name matched multiple volumes\"", " fi", " printf '%s' \"$matched\"", "}");
|
|
657
781
|
}
|
|
782
|
+
if (posixAllSharesParams.length > 0) {
|
|
783
|
+
lines.push("normalize_posix_all_shares() {", " value=\"$1\"", " case \"$value\" in", " \\[*\\]) value=\"$(printf '%s' \"$value\" | sed 's/^[[:space:]]*\\[//; s/\\][[:space:]]*$//; s/\"//g')\" ;;", " esac", " printf '%s' \"$value\" | awk -F, '{ for (i = 1; i <= NF; i++) { gsub(/^[ \\t]+|[ \\t]+$/, \"\", $i); if ($i != \"\") print $i } }'", "}", "resolve_posix_all_shares() {", " raw_list=\"$1\"", " normalized=\"$(normalize_posix_all_shares \"$raw_list\")\"", " [ -n \"$normalized\" ] || die \"POSIX_ALL_SHARES_EMPTY\"", " names=\"\"", " paths=\"\"", " count=0", " while IFS= read -r share_name; do", " case \"$share_name\" in", " */*|*..*|@*|'') die \"invalid Share name $share_name\" ;;", " *[[:cntrl:]]*) die \"invalid Share name $share_name\" ;;", " esac", " share_path=\"$(resolve_shared_folder \"$share_name\")\"", " names=\"${names}${names:+,}${share_name}\"", " paths=\"${paths}${paths:+,}${share_path}\"", " count=$((count + 1))", " done <<EOF", "$normalized", "EOF", " [ \"$count\" -gt 0 ] || die \"POSIX_ALL_SHARES_EMPTY\"", " printf '%s\\n%s\\n%s' \"$names\" \"$paths\" \"$count\"", "}", "rewrite_posix_all_shares() {", " p_name=\"$1\"", " share_names=\"$2\"", " share_paths=\"$3\"", " compose_file=\"$4\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v share_names=\"$share_names\" -v share_paths=\"$share_paths\" '", " BEGIN {", " name_count = split(share_names, names, \",\")", " path_count = split(share_paths, paths, \",\")", " marker = \"# synopkgland:posix_all_shares \" param", " pending = 0", " single_quote = sprintf(\"%c\", 39)", " }", " {", " if (index($0, marker) > 0 && $0 ~ /^[ \\t]*# synopkgland:posix_all_shares /) {", " print $0", " pending = 1", " next", " }", " if (!pending) { print $0; next }", " pending = 0", " line = $0", " indent = line", " sub(/[^ \\t].*$/, \"\", indent)", " entry = line", " sub(/^[ \\t]*-[ \\t]*/, \"\", entry)", " if (entry ~ /^\".*\"$/) entry = substr(entry, 2, length(entry) - 2)", " if (length(entry) >= 2 && substr(entry, 1, 1) == single_quote && substr(entry, length(entry), 1) == single_quote) entry = substr(entry, 2, length(entry) - 2)", " split(entry, fields, \":\")", " if (fields[1] != \"${\" param \"}\" || fields[2] == \"\") { print $0; next }", " mode = \"\"", " if (length(fields) >= 3) mode = \":\" fields[3]", " for (i = 1; i <= name_count && i <= path_count; i++) {", " print indent \"- \" paths[i] \":\" fields[2] \"/\" names[i] mode", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to expand POSIX all-shares in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
|
|
784
|
+
}
|
|
658
785
|
if (dsmUserParam) {
|
|
659
786
|
lines.push("resolve_dsm_user() {", " user_name=\"$1\"", " case \"$user_name\" in", " */*|*..*|*[[:cntrl:]]*) die \"invalid user name $user_name\" ;;", " '') die \"user name is empty\" ;;", " esac", " u_uid=\"$(id -u -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_uid\" in", " ''|*[!0-9]*) die \"resolved uid for $user_name is not a valid integer\" ;;", " 0) die \"resolved uid for $user_name cannot be 0 (root)\" ;;", " esac", " u_gid=\"$(id -g -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_gid\" in", " ''|*[!0-9]*) die \"resolved gid for $user_name is not a valid integer\" ;;", " esac", " # id -G returns all group ids including primary", " raw_groups=\"$(id -G -- \"$user_name\" 2>/dev/null || true)\"", " # filter out primary gid, deduplicate, keep numeric only", " supp_groups=\"\"", " for g in $raw_groups; do", " case \"$g\" in", " ''|*[!0-9]*) continue ;;", " esac", " if [ \"$g\" != \"$u_gid\" ]; then", " already=0", " for sg in $supp_groups; do", " if [ \"$sg\" = \"$g\" ]; then", " already=1", " break", " fi", " done", " if [ \"$already\" -eq 0 ]; then", " supp_groups=\"${supp_groups}${supp_groups:+ }$g\"", " fi", " fi", " done", " printf '%s %s %s' \"$u_uid\" \"$u_gid\" \"$supp_groups\"", "}", "rewrite_group_add() {", " p_name=\"$1\"", " g_list=\"$2\"", " compose_file=\"$3\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v g_list=\"$g_list\" '", " BEGIN {", " n = split(g_list, g_arr, \" \")", " }", " {", " regex = \"^[ \\t]*# synopkgland:group_add \" param \"$\"", " if ($0 ~ regex) {", " match($0, /^[ \\t]*/)", " indent = substr($0, RSTART, RLENGTH)", " if (n > 0) {", " print indent \"group_add:\"", " for (i = 1; i <= n; i++) {", " print indent \" - ${\" param \"_GROUP_\" i \"}\"", " }", " }", " } else {", " print $0", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to rewrite group_add in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
|
|
660
787
|
}
|
|
788
|
+
if (hasOidcCompat(contract)) {
|
|
789
|
+
const identity = contract.integration?.identity;
|
|
790
|
+
const redirectPath = identity?.oidc_compat?.redirect_path ?? "";
|
|
791
|
+
const adminProtocol = contract.adminprotocol ?? "http";
|
|
792
|
+
const adminPort = Number(contract.adminport ?? 0);
|
|
793
|
+
const escapedRedirectPath = redirectPath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
794
|
+
lines.push("oidc_register() {", " # DSM compose does not create missing bind sources: the secrets dir must exist even when compat is off.", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && chmod 700 \"$PROJECT/secrets\") || die \"failed to create $PROJECT/secrets\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " OIDC_CLIENT_SECRET=\"\"", " identity_file=\"${SYNOPKGLAND_IDENTITY_FILE:-/var/packages/SynoPkgLandService/var/identity.json}\"", " if [ ! -r \"$identity_file\" ]; then", " identity_file=\"/volume1/@appdata/SynoPkgLandService/identity.json\"", " fi", " if [ ! -r \"$identity_file\" ]; then", " # Identity endpoint not found -> Broker not installed -> skip as 404", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " base_url=\"$(python3 -c 'import json, sys; data=json.load(open(sys.argv[1])); print(data.get(\"baseUrl\", \"\"))' \"$identity_file\" 2>/dev/null || true)\"", " if [ -z \"$base_url\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_file=\"$T/.synopkgland/attestation.jwt\"", " if [ ! -f \"$attestation_file\" ]; then", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " return 0", " fi", " attestation_token=\"$(cat \"$attestation_file\")\"", " phase=\"install\"", " case \"$STATUS\" in", " UPGRADE) phase=\"upgrade\" ;;", " *) phase=\"install\" ;;", " esac", " req_body=\"$(python3 -c 'import json, sys; print(json.dumps({\"protocolVersion\": 1, \"attestation\": sys.argv[1], \"packageIdentity\": sys.argv[2], \"packageVersion\": sys.argv[3], \"redirectPath\": sys.argv[4], \"adminProtocol\": sys.argv[5], \"adminPort\": int(sys.argv[6]), \"phase\": sys.argv[7]}))' \"$attestation_token\" \"${SYNOPKG_PKGNAME:?}\" \"${SYNOPKG_PKGVER:?}\" \"" + escapedRedirectPath + "\" \"" + String(adminProtocol) + "\" \"" + String(adminPort) + "\" \"$phase\")\"", " resp_file=\"$(mktemp \"$T/oidc_resp.XXXXXX\")\"", " http_code=\"$(curl -s -S --connect-timeout 5 --max-time 30 -o \"$resp_file\" -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data-binary \"$req_body\" \"${base_url}/internal/v1/oidc/register\" 2>/dev/null || true)\"", " case \"$http_code\" in [0-9][0-9][0-9]) ;; *) http_code=\"000\" ;; esac", " if [ \"$http_code\" = \"200\" ]; then", " parsed_res=\"$(python3 -c 'import json, sys; d=json.load(open(sys.argv[1])); print(d.get(\"issuer\", \"\")); print(d.get(\"issuerHost\", \"\")); print(d.get(\"clientId\", \"\")); print(d.get(\"clientSecret\", \"\"))' \"$resp_file\" 2>/dev/null || true)\"", " rm -f \"$resp_file\"", " OIDC_ISSUER=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '1p')\"", " OIDC_ISSUER_HOST=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '2p')\"", " OIDC_CLIENT_ID=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '3p')\"", " OIDC_CLIENT_SECRET=\"$(printf '%s\\n' \"$parsed_res\" | sed -n '4p')\"", " (umask 077 && mkdir -p \"$PROJECT/secrets\" && printf '%s' \"$OIDC_CLIENT_SECRET\" > \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\")", " elif [ \"$http_code\" = \"404\" ] || [ \"$http_code\" = \"000\" ]; then", " rm -f \"$resp_file\"", " rm -f \"$PROJECT/secrets/SYNOPKGLAND_OIDC_CLIENT_SECRET\"", " OIDC_ISSUER=\"\"", " OIDC_ISSUER_HOST=\"oidc.invalid\"", " OIDC_CLIENT_ID=\"\"", " else", " rm -f \"$resp_file\"", " die \"OIDC_REGISTER_FAILED $http_code\"", " fi", "}", "oidc_register");
|
|
795
|
+
}
|
|
661
796
|
lines.push('tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"', 'chmod 600 "$tmp"', '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"', '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"', "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM", "{");
|
|
662
797
|
const valueVars = [];
|
|
663
798
|
for (const { name, param } of scalarParams) {
|
|
@@ -680,19 +815,37 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
680
815
|
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
681
816
|
`${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required shared folder parameter ${name}"`, " fi", ` case "$${rawVar}" in`, " /*) " + pathVar + "=\"$" + rawVar + "\" ;;", " *) " + pathVar + "=\"$(resolve_shared_folder \"$" + rawVar + "\")\" ;;", " esac");
|
|
682
817
|
}
|
|
818
|
+
const posixVars = [];
|
|
819
|
+
for (const { name } of posixAllSharesParams) {
|
|
820
|
+
const rawVar = "D54_RAW_POSIX_" + name;
|
|
821
|
+
const namesVar = "D54_POSIX_NAMES_" + name;
|
|
822
|
+
const pathsVar = "D54_POSIX_PATHS_" + name;
|
|
823
|
+
const countVar = "D54_POSIX_COUNT_" + name;
|
|
824
|
+
posixVars.push({ name, namesVar, pathsVar, countVar });
|
|
825
|
+
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
826
|
+
`${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required POSIX all-shares parameter ${name}"`, " fi", ` posix_info="$(resolve_posix_all_shares "$${rawVar}")"`, ` ${namesVar}="$(printf '%s\\n' "$posix_info" | sed -n '1p')"`, ` ${pathsVar}="$(printf '%s\\n' "$posix_info" | sed -n '2p')"`, ` ${countVar}="$(printf '%s\\n' "$posix_info" | sed -n '3p')"`, ` rewrite_posix_all_shares "${name}" "$${namesVar}" "$${pathsVar}" "$PROJECT/docker-compose.yml"`);
|
|
827
|
+
}
|
|
683
828
|
if (dsmUserParam) {
|
|
684
829
|
const name = dsmUserParam.name;
|
|
685
830
|
const rawUserVar = "D54_RAW_USER_" + name;
|
|
686
831
|
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
687
832
|
`${rawUserVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required dsm user parameter ${name}"`, " fi", ` resolved_user_info="$(resolve_dsm_user "$${rawUserVar}")"`, " set -- $resolved_user_info", ` ${name}_UID="$1"`, ` ${name}_GID="$2"`, " shift 2 || true", ` ${name}_GROUPS="$*"`, ` rewrite_group_add "${name}" "$${name}_GROUPS" "$PROJECT/docker-compose.yml"`);
|
|
688
833
|
}
|
|
689
|
-
|
|
834
|
+
if (hasOidcCompat(contract)) {
|
|
835
|
+
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "${SYNOPKG_PKGDEST_VOL:?}/@appstore/${SYNOPKG_PKGNAME:?}/compose/secrets"', ' append_env "SYNOPKGLAND_OIDC_ISSUER" "$OIDC_ISSUER"', ' append_env "SYNOPKGLAND_OIDC_ISSUER_HOST" "$OIDC_ISSUER_HOST"', ' append_env "SYNOPKGLAND_OIDC_CLIENT_ID" "$OIDC_CLIENT_ID"');
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
|
|
839
|
+
}
|
|
690
840
|
for (const { name, valueVar, type } of valueVars) {
|
|
691
841
|
lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
|
|
692
842
|
}
|
|
693
843
|
for (const { name, pathVar } of shareVars) {
|
|
694
844
|
lines.push(` append_env "${name}" "$${pathVar}" "string"`);
|
|
695
845
|
}
|
|
846
|
+
for (const { name, namesVar, countVar } of posixVars) {
|
|
847
|
+
lines.push(` append_env "${name}" "$${namesVar}" "string"`, ` append_env "${name}_COUNT" "$${countVar}" "string"`);
|
|
848
|
+
}
|
|
696
849
|
if (dsmUserParam) {
|
|
697
850
|
const name = dsmUserParam.name;
|
|
698
851
|
const rawUserVar = "D54_RAW_USER_" + name;
|
|
@@ -79,44 +79,29 @@ export function renderResourceConfig(contract, eligibility) {
|
|
|
79
79
|
*/
|
|
80
80
|
export function renderResourceWorkerStartStopStatus(packageId, adminport, headless = false) {
|
|
81
81
|
if (headless) {
|
|
82
|
-
const projectName = composeProjectName(packageId);
|
|
83
82
|
return `#!/bin/sh
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
PROJECT_NAME="${projectName}"
|
|
83
|
+
SYNOPKG_PKGNAME="\${SYNOPKG_PKGNAME:-${packageId}}"
|
|
84
|
+
SYNOPKGLAND_PROC_ROOT="\${SYNOPKGLAND_PROC_ROOT:-/proc}"
|
|
87
85
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
probe_project_process() {
|
|
87
|
+
for p in "$SYNOPKGLAND_PROC_ROOT"/[0-9]*; do
|
|
88
|
+
[ -d "$p" ] || continue
|
|
89
|
+
if grep -q docker "$p/cgroup" 2>/dev/null &&
|
|
90
|
+
grep -q \\
|
|
91
|
+
"@appstore/\${SYNOPKG_PKGNAME}/compose/" \\
|
|
92
|
+
"$p/mountinfo" 2>/dev/null; then
|
|
93
|
+
return 0
|
|
94
|
+
fi
|
|
95
95
|
done
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
marker_managed() {
|
|
100
|
-
[ -f "$MARKER" ] || return 1
|
|
101
|
-
[ ! -L "$MARKER" ] || return 1
|
|
102
|
-
[ "$(cat "$MARKER" 2>/dev/null || true)" = "managed" ]
|
|
96
|
+
return 1
|
|
103
97
|
}
|
|
104
98
|
|
|
105
99
|
case "$1" in
|
|
106
|
-
start|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
exit 0
|
|
111
|
-
fi
|
|
112
|
-
# Package user may not have a usable Docker inspection path. DSM owns the
|
|
113
|
-
# docker-project lifecycle; the postinst marker records that ownership.
|
|
114
|
-
if [ "$probe_status" -eq 2 ] && marker_managed; then
|
|
115
|
-
exit 0
|
|
116
|
-
fi
|
|
117
|
-
exit 3
|
|
100
|
+
start|stop) exit 0 ;;
|
|
101
|
+
status)
|
|
102
|
+
probe_project_process
|
|
103
|
+
exit $?
|
|
118
104
|
;;
|
|
119
|
-
stop) exit 0 ;;
|
|
120
105
|
*) exit 1 ;;
|
|
121
106
|
esac
|
|
122
107
|
`;
|
|
@@ -57,6 +57,7 @@ export declare function validateComposeNumericCommandArgs(compose: ComposeDocume
|
|
|
57
57
|
export interface HostBindContext {
|
|
58
58
|
sharedFolderParams: ReadonlySet<string>;
|
|
59
59
|
homesParams?: ReadonlySet<string>;
|
|
60
|
+
posixAllSharesParams?: ReadonlySet<string>;
|
|
60
61
|
installParamNames: ReadonlySet<string>;
|
|
61
62
|
}
|
|
62
63
|
export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;
|
package/dist/compose/analyze.js
CHANGED
|
@@ -190,6 +190,7 @@ const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
|
|
|
190
190
|
"boolean",
|
|
191
191
|
"shared_folder",
|
|
192
192
|
"dsm_user",
|
|
193
|
+
"posix_all_shares",
|
|
193
194
|
]);
|
|
194
195
|
/**
|
|
195
196
|
* Chooses the local route D0 can safely emit. The first dynamic slice is
|
|
@@ -227,10 +228,10 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
|
|
|
227
228
|
// Keep the route decision in one place. build-spk must not independently
|
|
228
229
|
// reinterpret an ineligible Compose as D1: only a route with a resolved
|
|
229
230
|
// published adminport or an eligible headless Compose, plus at least one
|
|
230
|
-
// declared install parameter, has a package-only materializer.
|
|
231
|
-
// buildable fallback is Broker.
|
|
231
|
+
// declared install parameter (or oidc_compat declaration), has a package-only materializer.
|
|
232
|
+
// Every other buildable fallback is Broker.
|
|
232
233
|
if ((eligibility.adminport !== undefined || headlessPackageOnly) &&
|
|
233
|
-
collectInstallParameters(contract).length > 0) {
|
|
234
|
+
(collectInstallParameters(contract).length > 0 || hasBrokerManagedOidc(contract))) {
|
|
234
235
|
return { route: "dynamic", eligibility, errors: [] };
|
|
235
236
|
}
|
|
236
237
|
return { route: "broker-installer", eligibility, errors: [] };
|
|
@@ -252,10 +253,14 @@ function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
|
252
253
|
const installParams = collectInstallParameters(contract);
|
|
253
254
|
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
254
255
|
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
256
|
+
const posixAllSharesParams = new Set(installParams
|
|
257
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
258
|
+
.map(({ name }) => name));
|
|
255
259
|
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
256
260
|
errors.push(...validateComposeHostBinds(compose, composeFile, {
|
|
257
261
|
sharedFolderParams,
|
|
258
262
|
homesParams,
|
|
263
|
+
posixAllSharesParams,
|
|
259
264
|
installParamNames,
|
|
260
265
|
}));
|
|
261
266
|
return dedupeValidationErrors(errors);
|
|
@@ -296,7 +301,8 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
296
301
|
(param.type === "string" ||
|
|
297
302
|
param.type === "boolean" ||
|
|
298
303
|
param.type === "shared_folder" ||
|
|
299
|
-
param.type === "dsm_user"
|
|
304
|
+
param.type === "dsm_user" ||
|
|
305
|
+
param.type === "posix_all_shares"))
|
|
300
306
|
.map(({ name }) => name));
|
|
301
307
|
const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
|
|
302
308
|
const dsmUserDerivedNames = dsmUserParam
|
|
@@ -310,8 +316,9 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
310
316
|
(param.type !== "string" &&
|
|
311
317
|
param.type !== "boolean" &&
|
|
312
318
|
param.type !== "shared_folder" &&
|
|
313
|
-
param.type !== "dsm_user"
|
|
314
|
-
|
|
319
|
+
param.type !== "dsm_user" &&
|
|
320
|
+
param.type !== "posix_all_shares"));
|
|
321
|
+
const brokerOidcInterpolationNames = hasBrokerManagedOidc(contract)
|
|
315
322
|
? BROKER_OIDC_INTERPOLATION_VARS
|
|
316
323
|
: [];
|
|
317
324
|
const supportedInterpolationNames = new Set([
|
|
@@ -614,6 +621,7 @@ const PLATFORM_BIND_VARS = new Set(["SYNOPKG_PKGVAR", "SYNO_APP_SECRETS_DIR"]);
|
|
|
614
621
|
const BROKER_OIDC_INTERPOLATION_VARS = new Set([
|
|
615
622
|
"SYNOPKGLAND_OIDC_ISSUER",
|
|
616
623
|
"SYNOPKGLAND_OIDC_CLIENT_ID",
|
|
624
|
+
"SYNOPKGLAND_OIDC_ISSUER_HOST",
|
|
617
625
|
]);
|
|
618
626
|
function hasBrokerManagedOidc(contract) {
|
|
619
627
|
if (!contract.integration || typeof contract.integration !== "object") {
|
|
@@ -725,7 +733,8 @@ function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile
|
|
|
725
733
|
}
|
|
726
734
|
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
727
735
|
context.sharedFolderParams.has(varName) ||
|
|
728
|
-
context.homesParams?.has(varName)
|
|
736
|
+
context.homesParams?.has(varName) ||
|
|
737
|
+
context.posixAllSharesParams?.has(varName)) {
|
|
729
738
|
return null;
|
|
730
739
|
}
|
|
731
740
|
if (context.installParamNames.has(varName)) {
|
|
@@ -39,12 +39,12 @@ export function validatePosixAllSharesSources(compose, composeFile, declaredPara
|
|
|
39
39
|
const path = `services.${serviceName}.volumes.${index}`;
|
|
40
40
|
if (declaredParameters.has(name)) {
|
|
41
41
|
if (suffix !== "") {
|
|
42
|
-
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由
|
|
42
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由 package-only preinst 在安裝時展開。`));
|
|
43
43
|
}
|
|
44
44
|
continue;
|
|
45
45
|
}
|
|
46
46
|
if (RESERVED_MARKER_NAMES.has(name)) {
|
|
47
|
-
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓
|
|
47
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓 wizard/CLI 取得安裝當下的 Share 清單。`));
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -30,7 +30,7 @@ function mapSchemaError(error, contractFile, parsed) {
|
|
|
30
30
|
}
|
|
31
31
|
export function validateContractSchema(parsed, contractFile) {
|
|
32
32
|
const validate = getValidator();
|
|
33
|
-
const data =
|
|
33
|
+
const data = parsed.doc.toJSON();
|
|
34
34
|
const valid = validate(data);
|
|
35
35
|
if (valid) {
|
|
36
36
|
return [];
|
|
@@ -48,28 +48,3 @@ export function validateContractSchema(parsed, contractFile) {
|
|
|
48
48
|
}
|
|
49
49
|
return result;
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Keep the published v1 schema's historical enum stable for older tooling,
|
|
53
|
-
* while allowing the P4 contract extension to be validated by the same v1
|
|
54
|
-
* object shape. The semantic validator and Broker retain the raw type.
|
|
55
|
-
*/
|
|
56
|
-
function schemaCompatibleContract(value) {
|
|
57
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
60
|
-
const contract = value;
|
|
61
|
-
const install = contract.install;
|
|
62
|
-
if (!install || typeof install !== "object" || Array.isArray(install)) {
|
|
63
|
-
return value;
|
|
64
|
-
}
|
|
65
|
-
for (const parameter of Object.values(install)) {
|
|
66
|
-
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
const record = parameter;
|
|
70
|
-
if (record.type === "posix_all_shares") {
|
|
71
|
-
record.type = "homes";
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
@@ -51,16 +51,6 @@ export function validateContract(parsed, contractFile) {
|
|
|
51
51
|
errors.push(...validateInstallParameters(parsed, contractFile));
|
|
52
52
|
errors.push(...validatePosixAllShares(parsed, contractFile));
|
|
53
53
|
errors.push(...validateWebLaunch(parsed, contractFile));
|
|
54
|
-
// The P4 worker cannot edit the shared host-bind analyzer. Its existing
|
|
55
|
-
// platform-resolved `homes` slot has the same analyzer semantics (declared
|
|
56
|
-
// interpolation is an allowed source and must be used). This normalization
|
|
57
|
-
// is local to the validation object; Broker and SPK generation read the raw
|
|
58
|
-
// YAML again and retain the actual posix_all_shares type.
|
|
59
|
-
for (const parameter of Object.values(contract.install ?? {})) {
|
|
60
|
-
if (parameter && isPosixAllSharesType(parameter.type)) {
|
|
61
|
-
parameter.type = "homes";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
54
|
return errors;
|
|
65
55
|
}
|
|
66
56
|
function validatePosixAllShares(parsed, contractFile) {
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -13,6 +13,7 @@ import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js"
|
|
|
13
13
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
14
14
|
import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
|
|
15
15
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
16
|
+
const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
|
|
16
17
|
function brokerCapabilitiesFromContract(project) {
|
|
17
18
|
const contract = loadContract(project.contractPath).contract;
|
|
18
19
|
const capabilities = ["identity"];
|
|
@@ -67,9 +68,34 @@ export async function deployProject(options) {
|
|
|
67
68
|
return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
|
|
68
69
|
}
|
|
69
70
|
const contract = loadContract(options.project.contractPath).contract;
|
|
71
|
+
const paramValues = { ...(options.paramValues ?? {}) };
|
|
72
|
+
const posixAllSharesNames = Object.entries(contract.install ?? {})
|
|
73
|
+
.filter(([, parameter]) => parameter?.type === "posix_all_shares")
|
|
74
|
+
.map(([name]) => name);
|
|
75
|
+
for (const name of posixAllSharesNames) {
|
|
76
|
+
if (Object.prototype.hasOwnProperty.call(paramValues, name)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let shareNames;
|
|
80
|
+
try {
|
|
81
|
+
shareNames = await options.host.listShareNames?.();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
shareNames = undefined;
|
|
85
|
+
}
|
|
86
|
+
const names = (shareNames ?? []).map((value) => value.trim()).filter(Boolean);
|
|
87
|
+
if (names.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
code: POSIX_ALL_SHARES_LOOKUP_FAILED,
|
|
91
|
+
message: `無法用管理員 session 列出這台 NAS 的 Shared Folder,無法填入 POSIX all-shares 參數「${name}」。`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
paramValues[name] = names.join(",");
|
|
95
|
+
}
|
|
70
96
|
const paramResolved = resolveInstallParameterExtraValues({
|
|
71
97
|
contract,
|
|
72
|
-
provided:
|
|
98
|
+
provided: paramValues,
|
|
73
99
|
});
|
|
74
100
|
if (!paramResolved.ok) {
|
|
75
101
|
return { ok: false, code: paramResolved.code, message: paramResolved.message };
|
|
@@ -129,8 +155,9 @@ export async function deployProject(options) {
|
|
|
129
155
|
}
|
|
130
156
|
}
|
|
131
157
|
}
|
|
158
|
+
let attestationResult;
|
|
132
159
|
try {
|
|
133
|
-
await options.host.requestAttestation({
|
|
160
|
+
attestationResult = await options.host.requestAttestation({
|
|
134
161
|
packageIdentity: identity.packageId,
|
|
135
162
|
contractDigest,
|
|
136
163
|
bundleDigest,
|
|
@@ -185,6 +212,7 @@ export async function deployProject(options) {
|
|
|
185
212
|
displayname: identity.displayname,
|
|
186
213
|
spkVersion,
|
|
187
214
|
archFamily,
|
|
215
|
+
embeddedAttestationToken: attestationResult.token,
|
|
188
216
|
});
|
|
189
217
|
if (!build.ok || !build.outputPath) {
|
|
190
218
|
return { ok: false, code: "BUILD_SPK_FAILED", message: build.errors.join(", ") };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { collectInstallParameters, stringViolations } from "../contract/load.js";
|
|
2
|
+
import { isPosixAllSharesType } from "../contract/posix-all-shares.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validate the local, non-secret values accepted by `deploy --param`.
|
|
4
5
|
*
|
|
@@ -30,7 +31,7 @@ export function resolveInstallParameterExtraValues(input) {
|
|
|
30
31
|
extraValues[name] = raw;
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
|
-
if (param.type !== "string") {
|
|
34
|
+
if (param.type !== "string" && !isPosixAllSharesType(param.type)) {
|
|
34
35
|
return {
|
|
35
36
|
ok: false,
|
|
36
37
|
code: "DEPLOY_PARAM_TYPE_INVALID",
|
package/dist/doctor.js
CHANGED
|
@@ -212,6 +212,11 @@ export async function runDoctor(options) {
|
|
|
212
212
|
message: "未指定 --host,略過 Trusted App Entry 檢查",
|
|
213
213
|
});
|
|
214
214
|
}
|
|
215
|
+
checks.push({
|
|
216
|
+
id: "host.oidc_origin",
|
|
217
|
+
status: "skip",
|
|
218
|
+
message: "未指定 --host,略過 OIDC origin 檢查",
|
|
219
|
+
});
|
|
215
220
|
}
|
|
216
221
|
else {
|
|
217
222
|
const host = options.host;
|
|
@@ -253,6 +258,11 @@ export async function runDoctor(options) {
|
|
|
253
258
|
status: "skip",
|
|
254
259
|
message: "NAS 無法連線,略過 Attestation Trust Root 檢查",
|
|
255
260
|
});
|
|
261
|
+
checks.push({
|
|
262
|
+
id: "host.oidc_origin",
|
|
263
|
+
status: "skip",
|
|
264
|
+
message: "NAS 無法連線,略過 OIDC origin 檢查",
|
|
265
|
+
});
|
|
256
266
|
if (options.packageId) {
|
|
257
267
|
checks.push({
|
|
258
268
|
id: "host.package_binding",
|
|
@@ -395,6 +405,50 @@ export async function runDoctor(options) {
|
|
|
395
405
|
}
|
|
396
406
|
}
|
|
397
407
|
}
|
|
408
|
+
// Check 2.3a: OIDC-compatible public origin. This is deliberately
|
|
409
|
+
// optional so a pre-A39 Broker remains a usable doctor target.
|
|
410
|
+
if (!isBrokerReady || !host.getOidcCompatSettings) {
|
|
411
|
+
checks.push({
|
|
412
|
+
id: "host.oidc_origin",
|
|
413
|
+
status: "skip",
|
|
414
|
+
message: !isBrokerReady
|
|
415
|
+
? "Broker 未就緒,略過 OIDC origin 檢查"
|
|
416
|
+
: "舊版 Broker 沒有 OIDC origin endpoint,略過",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
try {
|
|
421
|
+
const oidc = await host.getOidcCompatSettings();
|
|
422
|
+
if (!oidc) {
|
|
423
|
+
checks.push({
|
|
424
|
+
id: "host.oidc_origin",
|
|
425
|
+
status: "skip",
|
|
426
|
+
message: "舊版 Broker 沒有 OIDC origin endpoint,略過",
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
else if (oidc.origin.ready && oidc.origin.hostname) {
|
|
430
|
+
checks.push({
|
|
431
|
+
id: "host.oidc_origin",
|
|
432
|
+
status: "pass",
|
|
433
|
+
message: `${oidc.origin.kind} ${oidc.origin.hostname}`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
checks.push({
|
|
438
|
+
id: "host.oidc_origin",
|
|
439
|
+
status: "skip",
|
|
440
|
+
message: `未就緒(${oidc.origin.kind}):${oidc.origin.setupHint}`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
checks.push({
|
|
446
|
+
id: "host.oidc_origin",
|
|
447
|
+
status: "skip",
|
|
448
|
+
message: "OIDC origin endpoint 無法取得,略過",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
398
452
|
// Check 2.3b: DSM Residue check (A29)
|
|
399
453
|
if (isBrokerReady && host.getDsmDiagnostics) {
|
|
400
454
|
try {
|
package/dist/error-registry.js
CHANGED
|
@@ -83,7 +83,7 @@ export const ERROR_REGISTRY = {
|
|
|
83
83
|
CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN: {
|
|
84
84
|
code: "CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN",
|
|
85
85
|
description: "POSIX all-shares 參數不得在契約中指定 default 或 NAS 實體路徑。",
|
|
86
|
-
suggestion: "請只宣告 type: posix_all_shares,讓
|
|
86
|
+
suggestion: "請只宣告 type: posix_all_shares,讓 wizard/CLI 在安裝時取得當下的 Share snapshot。",
|
|
87
87
|
},
|
|
88
88
|
DSM_USER_REQUIRED: {
|
|
89
89
|
code: "DSM_USER_REQUIRED",
|
|
@@ -149,12 +149,12 @@ export const ERROR_REGISTRY = {
|
|
|
149
149
|
COMPOSE_POSIX_ALL_SHARES_UNDECLARED: {
|
|
150
150
|
code: "COMPOSE_POSIX_ALL_SHARES_UNDECLARED",
|
|
151
151
|
description: "Compose 使用了未宣告的 POSIX all-shares marker。",
|
|
152
|
-
suggestion: "請宣告 type: posix_all_shares,讓
|
|
152
|
+
suggestion: "請宣告 type: posix_all_shares,讓 package-only wizard/CLI 取得安裝當下的 Share snapshot。",
|
|
153
153
|
},
|
|
154
154
|
COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN: {
|
|
155
155
|
code: "COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN",
|
|
156
156
|
description: "POSIX all-shares marker 不得附加 Share 子路徑。",
|
|
157
|
-
suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由
|
|
157
|
+
suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由 package-only preinst 展開。",
|
|
158
158
|
},
|
|
159
159
|
COMPOSE_HOST_BIND_FORBIDDEN: {
|
|
160
160
|
code: "COMPOSE_HOST_BIND_FORBIDDEN",
|
|
@@ -529,6 +529,11 @@ export const ERROR_REGISTRY = {
|
|
|
529
529
|
description: "deploy --param 的值不符合契約宣告的 string 或 boolean 型別。",
|
|
530
530
|
suggestion: "string 請傳非空字串;boolean 請只傳 true 或 false。",
|
|
531
531
|
},
|
|
532
|
+
DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED: {
|
|
533
|
+
code: "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED",
|
|
534
|
+
description: "無法以管理員 session 列出部署當下的 Shared Folder 清單。",
|
|
535
|
+
suggestion: "請確認 DSM 管理員 session 可呼叫 SYNO.Core.Share list,或用 --param NAME=... 明確提供清單。",
|
|
536
|
+
},
|
|
532
537
|
DSM_DEPENDENCY_CONFLICT: {
|
|
533
538
|
code: "DSM_DEPENDENCY_CONFLICT",
|
|
534
539
|
description: "DSM 複合安裝請求因執行中的依賴套件而拒絕。",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -4,10 +4,11 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
6
6
|
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
|
-
import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
7
|
+
import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
8
|
import { dsmFetch } from "./dsm-http.js";
|
|
9
9
|
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
10
|
import { resolveHostCredentials } from "./credentials.js";
|
|
11
|
+
const OIDC_COMPAT_PATH = "/api/v1/inner-loop/oidc-compat";
|
|
11
12
|
export function createNasHost(options = {}) {
|
|
12
13
|
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
13
14
|
if (!credentials) {
|
|
@@ -160,7 +161,12 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
160
161
|
error.code = body.error?.code;
|
|
161
162
|
throw error;
|
|
162
163
|
}
|
|
163
|
-
|
|
164
|
+
const token = typeof body.token === "string" && body.token.length > 0 ? body.token : undefined;
|
|
165
|
+
return {
|
|
166
|
+
kind: "local",
|
|
167
|
+
attestationDigest: body.attestationDigest ?? "",
|
|
168
|
+
...(token !== undefined ? { token } : {}),
|
|
169
|
+
};
|
|
164
170
|
},
|
|
165
171
|
async ensurePackageSource(origin) {
|
|
166
172
|
ensurePackageSourceFeed(origin);
|
|
@@ -225,6 +231,9 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
225
231
|
async resolveSharePath(logicalName) {
|
|
226
232
|
return lookupSharePathWithSynoagentcli(logicalName, profileName);
|
|
227
233
|
},
|
|
234
|
+
async listShareNames() {
|
|
235
|
+
return listShareNamesWithSynoagentcli(profileName);
|
|
236
|
+
},
|
|
228
237
|
async resolveHomesPath() {
|
|
229
238
|
try {
|
|
230
239
|
const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
|
|
@@ -343,6 +352,51 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
343
352
|
return undefined;
|
|
344
353
|
}
|
|
345
354
|
},
|
|
355
|
+
async getOidcCompatSettings() {
|
|
356
|
+
try {
|
|
357
|
+
await ensureLogin();
|
|
358
|
+
const response = await dsmFetch(`${brokerBaseUrl}${OIDC_COMPAT_PATH}`, {
|
|
359
|
+
headers: { cookie: cookieHeader ?? "" },
|
|
360
|
+
});
|
|
361
|
+
const text = await response.text();
|
|
362
|
+
if (response.status === 404 || text.trimStart().startsWith("<")) {
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const payload = JSON.parse(text);
|
|
369
|
+
const settings = payload.settings;
|
|
370
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
const record = settings;
|
|
374
|
+
const origin = record.origin;
|
|
375
|
+
if (!origin || typeof origin !== "object" || Array.isArray(origin)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const originRecord = origin;
|
|
379
|
+
if (typeof record.enabled !== "boolean" ||
|
|
380
|
+
typeof originRecord.ready !== "boolean" ||
|
|
381
|
+
typeof originRecord.kind !== "string" ||
|
|
382
|
+
typeof originRecord.setupHint !== "string") {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
enabled: record.enabled,
|
|
387
|
+
origin: {
|
|
388
|
+
ready: originRecord.ready,
|
|
389
|
+
kind: originRecord.kind,
|
|
390
|
+
hostname: typeof originRecord.hostname === "string" ? originRecord.hostname : undefined,
|
|
391
|
+
issuerUrl: typeof originRecord.issuerUrl === "string" ? originRecord.issuerUrl : undefined,
|
|
392
|
+
setupHint: originRecord.setupHint,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
346
400
|
async smokeRequireLogin(input) {
|
|
347
401
|
await ensureLogin();
|
|
348
402
|
const info = await this.getAttestationInfo(input.packageIdentity);
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { AttestationKind, HostInstallRecord, NasHost, PackageRoute } from "./types.js";
|
|
1
|
+
import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
|
|
2
2
|
export interface FakeNasHostOptions {
|
|
3
3
|
curated?: string[];
|
|
4
4
|
dsmOrigin?: string;
|
|
5
5
|
homesPath?: string;
|
|
6
6
|
installationId?: string;
|
|
7
|
+
shareNames?: string[];
|
|
7
8
|
shares?: Record<string, string>;
|
|
8
9
|
installedEnv?: Record<string, Record<string, string>>;
|
|
9
10
|
diagnostics?: {
|
|
@@ -12,6 +13,8 @@ export interface FakeNasHostOptions {
|
|
|
12
13
|
isPackageOnly?: boolean;
|
|
13
14
|
[key: string]: unknown;
|
|
14
15
|
};
|
|
16
|
+
/** Omit to model an older Broker without the OIDC settings endpoint. */
|
|
17
|
+
oidcCompat?: HostOidcCompatSettings;
|
|
15
18
|
}
|
|
16
19
|
export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
|
|
17
20
|
installs: HostInstallRecord[];
|
package/dist/host/fake-nas.js
CHANGED
|
@@ -8,6 +8,11 @@ export function createFakeNasHost(options = {}) {
|
|
|
8
8
|
const installed = new Set();
|
|
9
9
|
const openUrls = new Map();
|
|
10
10
|
const shares = { ...(options.shares ?? {}) };
|
|
11
|
+
const shareNames = options.shareNames
|
|
12
|
+
? [...options.shareNames]
|
|
13
|
+
: Object.keys(shares).length > 0
|
|
14
|
+
? Object.keys(shares)
|
|
15
|
+
: ["docker", "home", "photo"];
|
|
11
16
|
const installedEnv = Object.fromEntries(Object.entries(options.installedEnv ?? {}).map(([packageId, env]) => [packageId, { ...env }]));
|
|
12
17
|
const host = {
|
|
13
18
|
dsmOrigin,
|
|
@@ -24,6 +29,9 @@ export function createFakeNasHost(options = {}) {
|
|
|
24
29
|
async resolveSharePath(logicalName) {
|
|
25
30
|
return shares[logicalName];
|
|
26
31
|
},
|
|
32
|
+
async listShareNames() {
|
|
33
|
+
return [...shareNames];
|
|
34
|
+
},
|
|
27
35
|
async resolveHomesPath() {
|
|
28
36
|
return options.homesPath;
|
|
29
37
|
},
|
|
@@ -49,7 +57,11 @@ export function createFakeNasHost(options = {}) {
|
|
|
49
57
|
}
|
|
50
58
|
const digest = `${input.packageIdentity}:${input.bundleDigest}:${input.contractDigest}`;
|
|
51
59
|
host.attestations.add(input.packageIdentity);
|
|
52
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
kind: "local",
|
|
62
|
+
attestationDigest: digest,
|
|
63
|
+
token: `fake-local-attestation.${input.packageIdentity}`,
|
|
64
|
+
};
|
|
53
65
|
},
|
|
54
66
|
async ensurePackageSource() { },
|
|
55
67
|
async installFromCatalog(input) {
|
|
@@ -111,6 +123,15 @@ export function createFakeNasHost(options = {}) {
|
|
|
111
123
|
async getDsmDiagnostics() {
|
|
112
124
|
return options.diagnostics;
|
|
113
125
|
},
|
|
126
|
+
async getOidcCompatSettings() {
|
|
127
|
+
if (!options.oidcCompat) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
enabled: options.oidcCompat.enabled,
|
|
132
|
+
origin: { ...options.oidcCompat.origin },
|
|
133
|
+
};
|
|
134
|
+
},
|
|
114
135
|
async smokeRequireLogin() {
|
|
115
136
|
return { ok: true, username: "admin" };
|
|
116
137
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function shareRealPathFromEntry(entry: Record<string, unknown>, logicalName: string): string | undefined;
|
|
2
2
|
export declare function findShareRealPath(payload: unknown, logicalName: string): string | undefined;
|
|
3
|
+
export declare function findShareNames(payload: unknown): string[];
|
|
3
4
|
export declare function lookupSharePathWithSynoagentcli(logicalName: string, profileName?: string): string | undefined;
|
|
5
|
+
export declare function listShareNamesWithSynoagentcli(profileName?: string): string[];
|
|
@@ -59,6 +59,16 @@ export function findShareRealPath(payload, logicalName) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
export function findShareNames(payload) {
|
|
63
|
+
const names = new Set();
|
|
64
|
+
for (const record of collectRecords(payload)) {
|
|
65
|
+
const name = record.name ?? record.share_name;
|
|
66
|
+
if (typeof name === "string" && name.trim() !== "") {
|
|
67
|
+
names.add(name.trim());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...names];
|
|
71
|
+
}
|
|
62
72
|
function synoagentcli(args, profileName) {
|
|
63
73
|
const fullArgs = [...args];
|
|
64
74
|
if (profileName) {
|
|
@@ -142,3 +152,10 @@ export function lookupSharePathWithSynoagentcli(logicalName, profileName) {
|
|
|
142
152
|
}
|
|
143
153
|
return lookupSharePathWithDsmHttp(logicalName, profileName);
|
|
144
154
|
}
|
|
155
|
+
export function listShareNamesWithSynoagentcli(profileName) {
|
|
156
|
+
const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
|
|
157
|
+
if (list.status !== 0) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return findShareNames(parseJson(list.stdout));
|
|
161
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { InstalledPackageState } from "./catalog-install.js";
|
|
2
2
|
export type AttestationKind = "local" | "app-package" | "none";
|
|
3
3
|
export type PackageRoute = "package-only" | "broker-installer" | "unknown";
|
|
4
|
+
export type OidcOriginKind = "public-ca" | "quickconnect" | "lan-ip" | "self-signed" | "private-ca" | "http";
|
|
5
|
+
export interface HostOidcOriginEvaluation {
|
|
6
|
+
ready: boolean;
|
|
7
|
+
kind: OidcOriginKind;
|
|
8
|
+
hostname?: string;
|
|
9
|
+
issuerUrl?: string;
|
|
10
|
+
setupHint: string;
|
|
11
|
+
}
|
|
12
|
+
export interface HostOidcCompatSettings {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
origin: HostOidcOriginEvaluation;
|
|
15
|
+
}
|
|
4
16
|
export interface DeployIdentity {
|
|
5
17
|
packageId: string;
|
|
6
18
|
displayname: string;
|
|
@@ -9,6 +21,7 @@ export interface DeployIdentity {
|
|
|
9
21
|
export interface AttestationIssueResult {
|
|
10
22
|
kind: "local";
|
|
11
23
|
attestationDigest: string;
|
|
24
|
+
token?: string;
|
|
12
25
|
}
|
|
13
26
|
export interface ResidueItem {
|
|
14
27
|
kind: "port_profile_sc" | "cm_project_config";
|
|
@@ -63,6 +76,8 @@ export interface NasHost {
|
|
|
63
76
|
* caller refuses instead of guessing.
|
|
64
77
|
*/
|
|
65
78
|
getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
|
|
79
|
+
/** List the current logical Shared Folder names using an administrator session. */
|
|
80
|
+
listShareNames?(): Promise<string[]>;
|
|
66
81
|
resolveSharePath?(logicalName: string): Promise<string | undefined>;
|
|
67
82
|
/** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
|
|
68
83
|
resolveHomesPath?(): Promise<string | undefined>;
|
|
@@ -79,6 +94,8 @@ export interface NasHost {
|
|
|
79
94
|
isPackageOnly?: boolean;
|
|
80
95
|
[key: string]: unknown;
|
|
81
96
|
} | undefined>;
|
|
97
|
+
/** Read the optional administrator OIDC compatibility settings endpoint. */
|
|
98
|
+
getOidcCompatSettings?(): Promise<HostOidcCompatSettings | undefined>;
|
|
82
99
|
smokeRequireLogin(input: {
|
|
83
100
|
packageIdentity: string;
|
|
84
101
|
cookieHeader: string;
|
package/dist/validate.js
CHANGED
|
@@ -68,10 +68,14 @@ export function validateProject(project, options = {}) {
|
|
|
68
68
|
const installParams = collectInstallParameters(parsed.contract);
|
|
69
69
|
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
70
70
|
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
71
|
+
const posixAllSharesParams = new Set(installParams
|
|
72
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
73
|
+
.map(({ name }) => name));
|
|
71
74
|
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
72
75
|
errors.push(...validateComposeHostBinds(parsedCompose.compose, composeFile, {
|
|
73
76
|
sharedFolderParams,
|
|
74
77
|
homesParams,
|
|
78
|
+
posixAllSharesParams,
|
|
75
79
|
installParamNames,
|
|
76
80
|
}));
|
|
77
81
|
if (typeof parsed.contract.adminport === "number") {
|
|
@@ -105,13 +109,14 @@ export function validateProject(project, options = {}) {
|
|
|
105
109
|
? { ...item, severity: (profile === "listing" ? "error" : "warning") }
|
|
106
110
|
: item));
|
|
107
111
|
for (const { name, param } of installParams) {
|
|
108
|
-
const referenced = param.type === "shared_folder" || param.type === "homes"
|
|
112
|
+
const referenced = param.type === "shared_folder" || param.type === "homes" || param.type === "posix_all_shares"
|
|
109
113
|
? volumeSourceVars.has(name)
|
|
110
114
|
: interpolations.has(name);
|
|
111
115
|
if ((param.type === "string" ||
|
|
112
116
|
param.type === "boolean" ||
|
|
113
117
|
param.type === "shared_folder" ||
|
|
114
|
-
param.type === "homes"
|
|
118
|
+
param.type === "homes" ||
|
|
119
|
+
param.type === "posix_all_shares") &&
|
|
115
120
|
!referenced) {
|
|
116
121
|
errors.push({
|
|
117
122
|
code: "INSTALL_PARAMETER_UNREFERENCED",
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"properties": {
|
|
60
60
|
"type": {
|
|
61
61
|
"type": "string",
|
|
62
|
-
"enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes"]
|
|
62
|
+
"enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes", "posix_all_shares"]
|
|
63
63
|
},
|
|
64
64
|
"default": {},
|
|
65
65
|
"generate": {
|
|
@@ -38,6 +38,10 @@ ports:
|
|
|
38
38
|
|
|
39
39
|
包現成 Compose 時,先用 `inspect` 看原本的 volume mapping。容器路徑跟 Compose/映像的 upstream target 走,不要為了套平台範例改成 `/data`;只把 host source 換成 `${PARAM}`。原有的 upstream named volume 要保留在頂層 `volumes:`。需要既有 Share 時宣告 `install.<PARAM>.type: shared_folder`,管理員在安裝時選這台 NAS 的 Share。
|
|
40
40
|
|
|
41
|
+
### `posix_all_shares`
|
|
42
|
+
|
|
43
|
+
當 App 必須在安裝當下掛載這台 NAS 目前列出的每一個共用資料夾時,宣告 `install.<NAME>.type: posix_all_shares`,並把 `${NAME}` 作為完整 Compose volume source,例如 `${NAME}:/shares:ro`。Package Center wizard 會以 `SYNO.Core.Share.list` 的 multi-select 讓管理員再次確認;這是最大警告:容器可讀(或依 Compose 模式讀寫)所有列出的 Share,安裝後新建的共用資料夾不會自動加入,由管理員決定是否安裝。不要在契約或 Compose 寫 `/volume*`、domain、實體 path;`CLI deploy` 未給 `--param NAME=...` 時會用管理員 session 重新列出當下的 Share,preinst 只解析清單,不掃描檔案系統。
|
|
44
|
+
|
|
41
45
|
## 路線判準:零設定(Static Zero-Config)vs Dynamic vs Broker
|
|
42
46
|
|
|
43
47
|
Syno Package Land 在打包 Compose App 時,依契約與 Compose 內容自動分類為不同路線:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 部署(Deploy)
|
|
2
2
|
|
|
3
|
-
Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center
|
|
3
|
+
Inner Loop:thin SPK、NAS Administrator DSM 授權、Local Attestation、經 Package Center 安裝。Personal deploy 會把 Broker 簽發的 local attestation 內嵌到套件供 preinst 使用。**不要 SSH**,不要把 Compose 貼進 Container Manager 手工裝。
|
|
4
4
|
|
|
5
5
|
## 前置
|
|
6
6
|
|