@synopackageland/cli 0.3.2 → 0.3.4
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 +57 -13
- package/dist/compose/analyze.d.ts +3 -1
- package/dist/compose/analyze.js +53 -5
- package/dist/contract/validate.js +7 -0
- package/dist/deploy/deploy.js +67 -3
- package/dist/deploy/identity-shared.d.ts +2 -0
- package/dist/deploy/identity-shared.js +2 -0
- package/dist/deploy/identity.d.ts +1 -1
- package/dist/deploy/identity.js +1 -1
- package/dist/deploy/spk-version.d.ts +9 -1
- package/dist/deploy/spk-version.js +57 -36
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +32 -2
- package/dist/error-registry.js +20 -0
- package/dist/host/catalog-install.d.ts +8 -0
- package/dist/host/catalog-install.js +60 -0
- package/dist/host/dsm-client.js +29 -1
- package/dist/host/fake-nas.d.ts +4 -0
- package/dist/host/fake-nas.js +3 -0
- package/dist/host/types.d.ts +16 -0
- package/dist/publish/curated-release.js +5 -1
- package/dist/publish/preflight.js +10 -1
- package/dist/templates/compose-import/compose.yaml +0 -1
- package/dist/templates/hello-files/compose.yaml +0 -1
- package/dist/validate.js +8 -16
- package/package.json +1 -1
- package/schema/doctor.schema.json +1 -1
- package/skills/SKILL.md +1 -1
- package/skills/reference/create.md +36 -0
- package/skills/reference/deploy.md +42 -2
- package/skills/reference/diagnose.md +17 -2
- package/templates/compose-import/compose.yaml +0 -1
- package/templates/hello-files/compose.yaml +0 -1
|
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import * as tar from "tar";
|
|
6
|
+
import { parseDocument, Scalar } from "yaml";
|
|
6
7
|
import { loadContract, collectInstallParameters } from "../contract/load.js";
|
|
7
8
|
import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, } from "../contract/web-launch.js";
|
|
8
9
|
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
@@ -138,9 +139,7 @@ export async function buildSpk(options) {
|
|
|
138
139
|
return { ok: false, errors: route.errors.map((error) => error.code) };
|
|
139
140
|
}
|
|
140
141
|
const rwEligibility = route?.eligibility;
|
|
141
|
-
const isDynamicRoute = route?.route === "dynamic"
|
|
142
|
-
rwEligibility?.adminport !== undefined &&
|
|
143
|
-
collectInstallParameters(contract).length > 0;
|
|
142
|
+
const isDynamicRoute = route?.route === "dynamic";
|
|
144
143
|
const isResourceWorkerRoute = route?.route === "static-zero-config" ||
|
|
145
144
|
isDynamicRoute;
|
|
146
145
|
const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
|
|
@@ -171,7 +170,27 @@ export async function buildSpk(options) {
|
|
|
171
170
|
if (isResourceWorkerRoute) {
|
|
172
171
|
const resourceProjectDir = join(staging, "package", "compose");
|
|
173
172
|
mkdirSync(resourceProjectDir, { recursive: true });
|
|
174
|
-
|
|
173
|
+
const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
|
|
174
|
+
if (dsmUserParam) {
|
|
175
|
+
const rawYaml = readFileSync(options.project.composePath, "utf8");
|
|
176
|
+
const doc = parseDocument(rawYaml);
|
|
177
|
+
const services = doc.get("services");
|
|
178
|
+
if (services && typeof services === "object" && typeof services.toJSON === "function") {
|
|
179
|
+
const serviceNames = Object.keys(services.toJSON());
|
|
180
|
+
for (const sName of serviceNames) {
|
|
181
|
+
const userVal = new Scalar(`\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}`);
|
|
182
|
+
userVal.type = Scalar.QUOTE_DOUBLE;
|
|
183
|
+
doc.setIn(["services", sName, "user"], userVal);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let composeContent = doc.toString();
|
|
187
|
+
const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
|
|
188
|
+
composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
|
|
189
|
+
writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
|
|
193
|
+
}
|
|
175
194
|
chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
|
|
176
195
|
}
|
|
177
196
|
const htmlDir = join(options.project.root, "html");
|
|
@@ -562,12 +581,15 @@ function renderUninstallWizard() {
|
|
|
562
581
|
}];
|
|
563
582
|
}
|
|
564
583
|
function renderDynamicPreinstMaterializer(contract) {
|
|
565
|
-
const
|
|
584
|
+
const allParams = collectInstallParameters(contract);
|
|
585
|
+
const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
|
|
586
|
+
const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
|
|
587
|
+
const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
|
|
566
588
|
const lines = [
|
|
567
589
|
"#!/bin/sh",
|
|
568
590
|
"set -eu",
|
|
569
591
|
"umask 077",
|
|
570
|
-
"# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
|
|
592
|
+
"# D3/D5.4 package-only carrier; values are wizard answers, canonical values, or defaults.",
|
|
571
593
|
"die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
|
|
572
594
|
'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
|
|
573
595
|
'STATUS="${SYNOPKG_PKG_STATUS:?}"',
|
|
@@ -611,15 +633,16 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
611
633
|
" *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
|
|
612
634
|
" esac",
|
|
613
635
|
"}",
|
|
614
|
-
'tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"',
|
|
615
|
-
'chmod 600 "$tmp"',
|
|
616
|
-
'[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
|
|
617
|
-
'[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"',
|
|
618
|
-
"trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
|
|
619
|
-
"{",
|
|
620
636
|
];
|
|
637
|
+
if (sharedFolderParams.length > 0) {
|
|
638
|
+
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\"", "}");
|
|
639
|
+
}
|
|
640
|
+
if (dsmUserParam) {
|
|
641
|
+
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\"", "}");
|
|
642
|
+
}
|
|
643
|
+
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", "{");
|
|
621
644
|
const valueVars = [];
|
|
622
|
-
for (const { name, param } of
|
|
645
|
+
for (const { name, param } of scalarParams) {
|
|
623
646
|
const type = param.type;
|
|
624
647
|
const valueVar = "D3_VALUE_" + name;
|
|
625
648
|
valueVars.push({ name, valueVar, type });
|
|
@@ -631,10 +654,31 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
631
654
|
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
632
655
|
`${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
|
|
633
656
|
}
|
|
657
|
+
const shareVars = [];
|
|
658
|
+
for (const { name } of sharedFolderParams) {
|
|
659
|
+
const rawVar = "D54_RAW_SHARE_" + name;
|
|
660
|
+
const pathVar = "D54_RESOLVED_PATH_" + name;
|
|
661
|
+
shareVars.push({ name, pathVar });
|
|
662
|
+
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
663
|
+
`${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");
|
|
664
|
+
}
|
|
665
|
+
if (dsmUserParam) {
|
|
666
|
+
const name = dsmUserParam.name;
|
|
667
|
+
const rawUserVar = "D54_RAW_USER_" + name;
|
|
668
|
+
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawUserVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
669
|
+
`${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"`);
|
|
670
|
+
}
|
|
634
671
|
lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
|
|
635
672
|
for (const { name, valueVar, type } of valueVars) {
|
|
636
673
|
lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
|
|
637
674
|
}
|
|
675
|
+
for (const { name, pathVar } of shareVars) {
|
|
676
|
+
lines.push(` append_env "${name}" "$${pathVar}" "string"`);
|
|
677
|
+
}
|
|
678
|
+
if (dsmUserParam) {
|
|
679
|
+
const name = dsmUserParam.name;
|
|
680
|
+
lines.push(` append_env "${name}_UID" "$${name}_UID" "string"`, ` append_env "${name}_GID" "$${name}_GID" "string"`, ` idx=1`, ` for g in $${name}_GROUPS; do`, ` append_env "${name}_GROUP_\${idx}" "$g" "string"`, ` idx=$((idx + 1))`, ` done`);
|
|
681
|
+
}
|
|
638
682
|
lines.push(" } > \"$tmp\"", " mv \"$tmp\" \"$RUNTIME_ENV\"", " trap - EXIT HUP INT TERM", " [ ! -L \"$RUNTIME_ENV\" ] || die \"runtime file became a symlink\"", " [ \"$(stat -c %a \"$RUNTIME_ENV\")\" = \"600\" ] || die \"runtime env mode is not 0600\"", "exit 0");
|
|
639
683
|
return lines.join("\n") + "\n";
|
|
640
684
|
}
|
|
@@ -27,7 +27,7 @@ export interface ResourceWorkerEligibility {
|
|
|
27
27
|
* - any App without a unique literal published adminport status probe
|
|
28
28
|
*/
|
|
29
29
|
export declare function resourceWorkerEligibility(contract: AppContract, compose: ComposeDocument): ResourceWorkerEligibility;
|
|
30
|
-
export type ComposeRoute = "static-zero-config" | "dynamic" | "reject";
|
|
30
|
+
export type ComposeRoute = "static-zero-config" | "dynamic" | "broker-installer" | "reject";
|
|
31
31
|
export interface ComposeRouteClassification {
|
|
32
32
|
route: ComposeRoute;
|
|
33
33
|
eligibility: ResourceWorkerEligibility;
|
|
@@ -41,6 +41,7 @@ export interface ComposeRouteClassification {
|
|
|
41
41
|
* placed in the project .env.
|
|
42
42
|
*/
|
|
43
43
|
export declare function classifyComposeRoute(contract: AppContract, parsed: ParsedCompose, composeFile?: string): ComposeRouteClassification;
|
|
44
|
+
export declare function brokerInstallerRouteWarning(compose: ComposeDocument, composeFile?: string): ValidationError;
|
|
44
45
|
export declare function collectComposeInterpolations(compose: ComposeDocument): Set<string>;
|
|
45
46
|
export declare function validateComposeSecurity(compose: ComposeDocument, composeFile: string, options: {
|
|
46
47
|
profile: "personal" | "listing";
|
|
@@ -61,5 +62,6 @@ export declare function collectVolumeSourceInterpolations(compose: ComposeDocume
|
|
|
61
62
|
export declare function validateComposeHostBinds(compose: ComposeDocument, composeFile: string, context: HostBindContext): ValidationError[];
|
|
62
63
|
export declare function validateComposeRunAs(compose: ComposeDocument, composeFile: string, context: {
|
|
63
64
|
hasSharedFolder: boolean;
|
|
65
|
+
hasDsmUser?: boolean;
|
|
64
66
|
}): ValidationError[];
|
|
65
67
|
export declare function validateAdminPortMapping(adminport: number, compose: ComposeDocument, composeFile: string): ValidationError[];
|
package/dist/compose/analyze.js
CHANGED
|
@@ -204,7 +204,25 @@ export function classifyComposeRoute(contract, parsed, composeFile = "compose.ya
|
|
|
204
204
|
if (eligibility.eligible) {
|
|
205
205
|
return { route: "static-zero-config", eligibility, errors: [] };
|
|
206
206
|
}
|
|
207
|
-
|
|
207
|
+
// Keep the route decision in one place. build-spk must not independently
|
|
208
|
+
// reinterpret an ineligible Compose as D1: only a route with a resolved
|
|
209
|
+
// published adminport and at least one declared install parameter has a
|
|
210
|
+
// package-only materializer. Every other buildable fallback is Broker.
|
|
211
|
+
if (eligibility.adminport !== undefined && collectInstallParameters(contract).length > 0) {
|
|
212
|
+
return { route: "dynamic", eligibility, errors: [] };
|
|
213
|
+
}
|
|
214
|
+
return { route: "broker-installer", eligibility, errors: [] };
|
|
215
|
+
}
|
|
216
|
+
export function brokerInstallerRouteWarning(compose, composeFile = "compose.yaml") {
|
|
217
|
+
const path = findDynamicComposePath(compose) ?? "services";
|
|
218
|
+
return makeError("CURATED_BROKER_ROUTE_FORBIDDEN", composeSpanForPath(compose, composeFile, path), "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,無法作為 Curated production runtime。", "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。", "warning");
|
|
219
|
+
}
|
|
220
|
+
function hasImmutableServiceImages(compose) {
|
|
221
|
+
const services = compose.services;
|
|
222
|
+
if (!services || Object.keys(services).length === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return Object.values(services).every((service) => typeof service.image === "string" && /@sha256:[a-f0-9]{64}$/.test(service.image.trim()));
|
|
208
226
|
}
|
|
209
227
|
function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
210
228
|
const { compose } = parsed;
|
|
@@ -226,6 +244,7 @@ function collectPermanentRouteErrors(contract, parsed, composeFile) {
|
|
|
226
244
|
}
|
|
227
245
|
const { compose } = parsed;
|
|
228
246
|
const errors = [];
|
|
247
|
+
const dsmUserParams = collectInstallParameters(contract).filter(({ param }) => param.type === "dsm_user");
|
|
229
248
|
if (compose.services) {
|
|
230
249
|
for (const [serviceName, service] of Object.entries(compose.services)) {
|
|
231
250
|
const basePath = `services.${serviceName}`;
|
|
@@ -233,6 +252,10 @@ function collectPermanentRouteErrors(contract, parsed, composeFile) {
|
|
|
233
252
|
const path = `${basePath}.build`;
|
|
234
253
|
errors.push(makeError("COMPOSE_BUILD_FORBIDDEN", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」宣告 build:;D0 route 不支援 install-time build。`, "請移除 build:,改用已存在的 image(最好使用 image@sha256:... 固定 digest),再重新執行 validate。"));
|
|
235
254
|
}
|
|
255
|
+
if (dsmUserParams.length > 0 && typeof service.user === "string" && service.user.trim() !== "") {
|
|
256
|
+
const path = `${basePath}.user`;
|
|
257
|
+
errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", composeSpanForPath(compose, composeFile, path), `service「${serviceName}」已定義 user:「${service.user.trim()}」,與 contract 宣告的 dsm_user 衝突。`, "請從 Compose 移除 user 欄位,由 generator 依 dsm_user 自動注入 user: \"${NAME_UID}:${NAME_GID}\" 與 group_add。"));
|
|
258
|
+
}
|
|
236
259
|
errors.push(...validateSocketMounts(compose, service, composeFile, `${basePath}.volumes`));
|
|
237
260
|
}
|
|
238
261
|
}
|
|
@@ -247,15 +270,30 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
247
270
|
const hasUnsupportedComposeFiles = hasComposeSecretsOrConfigs(compose);
|
|
248
271
|
const supportedParamNames = new Set(params
|
|
249
272
|
.filter(({ name, param }) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
250
|
-
(param.type === "string" ||
|
|
273
|
+
(param.type === "string" ||
|
|
274
|
+
param.type === "boolean" ||
|
|
275
|
+
param.type === "shared_folder" ||
|
|
276
|
+
param.type === "dsm_user"))
|
|
251
277
|
.map(({ name }) => name));
|
|
278
|
+
const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
|
|
279
|
+
const dsmUserDerivedNames = dsmUserParam
|
|
280
|
+
? [
|
|
281
|
+
`${dsmUserParam.name}_UID`,
|
|
282
|
+
`${dsmUserParam.name}_GID`,
|
|
283
|
+
...Array.from({ length: 8 }, (_, i) => `${dsmUserParam.name}_GROUP_${i + 1}`),
|
|
284
|
+
]
|
|
285
|
+
: [];
|
|
252
286
|
const unsupportedParam = params.find(({ name, param }) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ||
|
|
253
|
-
(param.type !== "string" &&
|
|
287
|
+
(param.type !== "string" &&
|
|
288
|
+
param.type !== "boolean" &&
|
|
289
|
+
param.type !== "shared_folder" &&
|
|
290
|
+
param.type !== "dsm_user"));
|
|
254
291
|
const brokerOidcInterpolationNames = params.length === 0 && hasBrokerManagedOidc(contract)
|
|
255
292
|
? BROKER_OIDC_INTERPOLATION_VARS
|
|
256
293
|
: [];
|
|
257
294
|
const supportedInterpolationNames = new Set([
|
|
258
295
|
...supportedParamNames,
|
|
296
|
+
...dsmUserDerivedNames,
|
|
259
297
|
...PLATFORM_BIND_VARS,
|
|
260
298
|
...brokerOidcInterpolationNames,
|
|
261
299
|
]);
|
|
@@ -429,10 +467,16 @@ function walkForInterpolation(value, names) {
|
|
|
429
467
|
export function validateComposeSecurity(compose, composeFile, options) {
|
|
430
468
|
const errors = [];
|
|
431
469
|
const contract = composeContracts.get(compose);
|
|
432
|
-
const
|
|
433
|
-
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
|
|
470
|
+
const route = contract
|
|
471
|
+
? classifyComposeRoute(contract, parsedComposeForAnalysis(compose), composeFile)
|
|
472
|
+
: undefined;
|
|
473
|
+
const routeErrors = route
|
|
474
|
+
? route.errors.filter((error) => !HOST_BIND_ERROR_CODES.has(error.code))
|
|
434
475
|
: [];
|
|
435
476
|
errors.push(...routeErrors);
|
|
477
|
+
if (route?.route === "broker-installer" && hasImmutableServiceImages(compose)) {
|
|
478
|
+
errors.push(brokerInstallerRouteWarning(compose, composeFile));
|
|
479
|
+
}
|
|
436
480
|
const routeErrorKeys = new Set(routeErrors.map(validationErrorKey));
|
|
437
481
|
if (!compose.services) {
|
|
438
482
|
return errors;
|
|
@@ -761,6 +805,10 @@ export function validateComposeRunAs(compose, composeFile, context) {
|
|
|
761
805
|
}
|
|
762
806
|
const user = raw.trim();
|
|
763
807
|
const span = composeSpanForPath(compose, composeFile, `services.${serviceName}.user`);
|
|
808
|
+
if (context.hasDsmUser) {
|
|
809
|
+
errors.push(makeError("COMPOSE_USER_CONFLICTS_DSM_USER", span, `service「${serviceName}」已定義 user:「${user}」,與 contract 宣告的 dsm_user 衝突。`, "請從 Compose 移除 user 欄位,由 generator 依 dsm_user 自動注入 user: \"${NAME_UID}:${NAME_GID}\" 與 group_add。"));
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
764
812
|
if (user.includes("${")) {
|
|
765
813
|
continue;
|
|
766
814
|
}
|
|
@@ -6,6 +6,7 @@ import { validateContractSchema } from "./schema-validate.js";
|
|
|
6
6
|
import { isWebLaunchMode, readWebIntegration } from "./web-launch.js";
|
|
7
7
|
import { loadCompose } from "../compose/analyze.js";
|
|
8
8
|
import { collectPosixAllSharesParameters, isPosixAllSharesType, validatePosixAllSharesSources, } from "./posix-all-shares.js";
|
|
9
|
+
import { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, } from "../deploy/identity-shared.js";
|
|
9
10
|
/** DSM 7.4 Package Center rejects a longer package identity outright. */
|
|
10
11
|
const DSM_PACKAGE_IDENTITY_MAX = 32;
|
|
11
12
|
const METADATA_STRING_FIELDS = [
|
|
@@ -35,6 +36,12 @@ export function validateContract(parsed, contractFile) {
|
|
|
35
36
|
const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
|
|
36
37
|
errors.push(makeError("CONTRACT_PACKAGE_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, "package"), `Package Identity 超過 DSM 上限 ${DSM_PACKAGE_IDENTITY_MAX} 字元(目前 ${contract.package.length})。`, "請縮短 package,否則 Package Center 會以「Invalid file format」拒絕安裝。"));
|
|
37
38
|
}
|
|
39
|
+
// Formal Package Identity length limit (28 characters to reserve 4 characters for .dev suffix)
|
|
40
|
+
if (typeof contract.package === "string" &&
|
|
41
|
+
contract.package.length > FORMAL_PACKAGE_IDENTITY_MAX_LENGTH) {
|
|
42
|
+
const pos = getValuePosition(doc, ["package"]) ?? { line: 1, column: 1 };
|
|
43
|
+
errors.push(makeError("PACKAGE_IDENTITY_TOO_LONG", contractSpan(contractFile, pos.line, pos.column, "package"), `Package Identity 長度 ${contract.package.length} 超過正式身分上限 ${FORMAL_PACKAGE_IDENTITY_MAX_LENGTH} 字元(DSM 32 字元上限,.dev 需保留 4 字元)。`, `請將 package 縮短至 ${FORMAL_PACKAGE_IDENTITY_MAX_LENGTH} 字元以內,以利 --dev 部署(上限 ${PACKAGE_IDENTITY_MAX_LENGTH} 字元)。`));
|
|
44
|
+
}
|
|
38
45
|
if (contract.adminport !== undefined) {
|
|
39
46
|
if (typeof contract.adminport !== "number" || !Number.isInteger(contract.adminport)) {
|
|
40
47
|
const pos = getValuePosition(doc, ["adminport"]) ?? { line: 1, column: 1 };
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -5,12 +5,13 @@ import { digestProjectBundle, digestProjectContract } from "../host/digests.js";
|
|
|
5
5
|
import { appOriginFromContract } from "../host/open-url.js";
|
|
6
6
|
import { validateProject } from "../validate.js";
|
|
7
7
|
import { publishBuiltSpk, packageSourceOrigin } from "./catalog-publish.js";
|
|
8
|
-
import { resolveDeployIdentity } from "./identity.js";
|
|
8
|
+
import { resolveDeployIdentity, PACKAGE_IDENTITY_MAX_LENGTH } from "./identity.js";
|
|
9
9
|
import { resolveDeploySpkVersion } from "./spk-version.js";
|
|
10
10
|
import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
|
|
11
11
|
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
12
12
|
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
13
13
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
14
|
+
import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
|
|
14
15
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
15
16
|
function brokerCapabilitiesFromContract(project) {
|
|
16
17
|
const contract = loadContract(project.contractPath).contract;
|
|
@@ -21,12 +22,50 @@ function brokerCapabilitiesFromContract(project) {
|
|
|
21
22
|
}
|
|
22
23
|
return capabilities;
|
|
23
24
|
}
|
|
25
|
+
function versionBuildIsCurated(version) {
|
|
26
|
+
if (!version) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const match = /-(\d+)$/.exec(version);
|
|
30
|
+
if (!match) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const build = Number(match[1]);
|
|
34
|
+
return Number.isSafeInteger(build) && build >= CURATED_BUILD_OFFSET;
|
|
35
|
+
}
|
|
36
|
+
async function detectCuratedInstall(host, packageId) {
|
|
37
|
+
let brokerRecord = false;
|
|
38
|
+
try {
|
|
39
|
+
brokerRecord = (await host.getAttestationInfo(packageId)).attestationKind === "app-package";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// requestAttestation below remains the authoritative error path when the Broker info probe is unavailable.
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
brokerRecord,
|
|
46
|
+
installedVersion: await host.getInstalledPackageVersion?.(packageId),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
24
49
|
export async function deployProject(options) {
|
|
25
50
|
const validation = validateProject(options.project);
|
|
26
51
|
if (!validation.ok) {
|
|
27
52
|
return { ok: false, code: "VALIDATION_FAILED", message: validation.errors.map((e) => e.code).join(", ") };
|
|
28
53
|
}
|
|
29
54
|
const identity = resolveDeployIdentity(options.project, { dev: options.dev });
|
|
55
|
+
if (identity.packageId.length > PACKAGE_IDENTITY_MAX_LENGTH) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: "PACKAGE_IDENTITY_TOO_LONG",
|
|
59
|
+
message: `最終 Package Identity「${identity.packageId}」長度 ${identity.packageId.length} 超過 DSM 上限 ${PACKAGE_IDENTITY_MAX_LENGTH} 字元。`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const curatedInstall = identity.developmentInstance
|
|
63
|
+
? undefined
|
|
64
|
+
: await detectCuratedInstall(options.host, identity.packageId);
|
|
65
|
+
const hasCuratedInstall = curatedInstall?.brokerRecord === true || versionBuildIsCurated(curatedInstall?.installedVersion);
|
|
66
|
+
if (hasCuratedInstall && !options.replaceCurated) {
|
|
67
|
+
return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
|
|
68
|
+
}
|
|
30
69
|
const contract = loadContract(options.project.contractPath).contract;
|
|
31
70
|
const paramResolved = resolveInstallParameterExtraValues({
|
|
32
71
|
contract,
|
|
@@ -67,6 +106,29 @@ export async function deployProject(options) {
|
|
|
67
106
|
};
|
|
68
107
|
const contractDigest = digestProjectContract(options.project);
|
|
69
108
|
const bundleDigest = digestProjectBundle(options.project);
|
|
109
|
+
if (hasCuratedInstall && options.replaceCurated) {
|
|
110
|
+
const installed = await options.host.isPackageInstalled(identity.packageId);
|
|
111
|
+
if (installed) {
|
|
112
|
+
console.warn("警告:--replace-curated 會移除 Curated 安裝,但會保留 App Data。");
|
|
113
|
+
if (!options.host.uninstallPackage) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
code: "CATALOG_INSTALL_FAILED",
|
|
117
|
+
message: "此 NAS Host 不支援保留 App Data 的 Curated replacement uninstall。",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await options.host.uninstallPackage(identity.packageId);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
code: "CATALOG_INSTALL_FAILED",
|
|
127
|
+
message: error instanceof Error ? error.message : String(error),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
70
132
|
try {
|
|
71
133
|
await options.host.requestAttestation({
|
|
72
134
|
packageIdentity: identity.packageId,
|
|
@@ -95,10 +157,12 @@ export async function deployProject(options) {
|
|
|
95
157
|
}
|
|
96
158
|
catch (error) {
|
|
97
159
|
const code = error.code;
|
|
98
|
-
if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
|
|
160
|
+
if (code === "DEPLOY_PERSONAL_BUILD_EXHAUSTED" ||
|
|
161
|
+
code === "PERSONAL_BUILD_EXHAUSTED" ||
|
|
162
|
+
code === "DEPLOY_VERSION_NOT_ASCENDING") {
|
|
99
163
|
return {
|
|
100
164
|
ok: false,
|
|
101
|
-
code: "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
|
|
165
|
+
code: code === "DEPLOY_VERSION_NOT_ASCENDING" ? code : "DEPLOY_PERSONAL_BUILD_EXHAUSTED",
|
|
102
166
|
message: error instanceof Error ? error.message : String(error),
|
|
103
167
|
};
|
|
104
168
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const PACKAGE_IDENTITY_MAX_LENGTH = 32;
|
|
2
|
+
export declare const FORMAL_PACKAGE_IDENTITY_MAX_LENGTH = 28;
|
|
1
3
|
export declare function developmentPackageIdentity(packageIdentity: string): string;
|
|
2
4
|
export declare function isDevelopmentPackageIdentity(packageIdentity: string): boolean;
|
|
3
5
|
export declare function developmentDisplayName(displayname: string): string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ProjectRoot } from "../discovery.js";
|
|
2
2
|
import type { DeployIdentity } from "../host/types.js";
|
|
3
|
-
export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
3
|
+
export { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
4
4
|
export interface ResolveDeployIdentityOptions {
|
|
5
5
|
dev?: boolean;
|
|
6
6
|
}
|
package/dist/deploy/identity.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { developmentDisplayName, developmentPackageIdentity, } from "./identity-shared.js";
|
|
2
2
|
import { loadContract } from "../contract/load.js";
|
|
3
|
-
export { developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
3
|
+
export { FORMAL_PACKAGE_IDENTITY_MAX_LENGTH, PACKAGE_IDENTITY_MAX_LENGTH, developmentDisplayName, developmentPackageIdentity, isDevelopmentPackageIdentity, } from "./identity-shared.js";
|
|
4
4
|
export function resolveDeployIdentity(project, options) {
|
|
5
5
|
const parsed = loadContract(project.contractPath);
|
|
6
6
|
const basePackage = String(parsed.contract.package ?? "");
|
|
@@ -4,6 +4,14 @@ export declare class DeployPersonalBuildExhaustedError extends Error {
|
|
|
4
4
|
readonly code = "DEPLOY_PERSONAL_BUILD_EXHAUSTED";
|
|
5
5
|
constructor();
|
|
6
6
|
}
|
|
7
|
-
export declare
|
|
7
|
+
export declare class DeployVersionNotAscendingError extends Error {
|
|
8
|
+
readonly code = "DEPLOY_VERSION_NOT_ASCENDING";
|
|
9
|
+
constructor(contractVersion: string, proposedVersion: string, existingVersion: string);
|
|
10
|
+
}
|
|
11
|
+
export interface DeployVersionCandidates {
|
|
12
|
+
installed?: string;
|
|
13
|
+
feed?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolvePersonalDeploySpkVersion(contractVersion: string, candidates?: DeployVersionCandidates): string;
|
|
8
16
|
export declare function resolveDeploySpkVersion(project: ProjectRoot, host: NasHost, packageId: string): Promise<string>;
|
|
9
17
|
export declare function publishDeploySpk(spkPath: string): Promise<string>;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { loadContract } from "../contract/load.js";
|
|
2
2
|
import { comparePackageVersions, CURATED_BUILD_OFFSET, PERSONAL_BUILD_MAX, } from "@synopackageland/package-source/index-manifest";
|
|
3
|
-
import { PersonalBuildExhaustedError, resolveNextSpkVersion, } from "@synopackageland/package-source/next-version";
|
|
4
3
|
import { fetchPackageSourceManifest, fetchPackageSourceManifestFromR2, resolvePublishTarget, } from "@synopackageland/package-source/publish-spk";
|
|
5
4
|
import { publishBuiltSpk } from "./catalog-publish.js";
|
|
6
5
|
import { isDevelopmentPackageIdentity } from "./identity-shared.js";
|
|
@@ -12,32 +11,61 @@ export class DeployPersonalBuildExhaustedError extends Error {
|
|
|
12
11
|
this.name = "DeployPersonalBuildExhaustedError";
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const build = Number(match[1]);
|
|
21
|
-
if (!Number.isSafeInteger(build) || build >= CURATED_BUILD_OFFSET) {
|
|
22
|
-
throw new DeployPersonalBuildExhaustedError();
|
|
14
|
+
export class DeployVersionNotAscendingError extends Error {
|
|
15
|
+
code = "DEPLOY_VERSION_NOT_ASCENDING";
|
|
16
|
+
constructor(contractVersion, proposedVersion, existingVersion) {
|
|
17
|
+
super(`Contract version ${contractVersion} would produce ${proposedVersion}, which is not higher than installed package version ${existingVersion}.`);
|
|
18
|
+
this.name = "DeployVersionNotAscendingError";
|
|
23
19
|
}
|
|
24
|
-
return contractVersion;
|
|
25
20
|
}
|
|
26
|
-
|
|
21
|
+
function baseVersion(version) {
|
|
27
22
|
const match = /^(.+)-(\d+)$/.exec(version);
|
|
23
|
+
return match ? match[1] : version;
|
|
24
|
+
}
|
|
25
|
+
function buildNumber(version) {
|
|
26
|
+
const match = /^.+-(\d+)$/.exec(version);
|
|
28
27
|
if (!match) {
|
|
29
|
-
return
|
|
28
|
+
return undefined;
|
|
30
29
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const build = Number(match[1]);
|
|
31
|
+
return Number.isSafeInteger(build) ? build : undefined;
|
|
32
|
+
}
|
|
33
|
+
function personalBuildNumber(version) {
|
|
34
|
+
const build = buildNumber(version);
|
|
35
|
+
return build !== undefined && build >= 1 && build <= PERSONAL_BUILD_MAX ? build : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function resolvePersonalDeploySpkVersion(contractVersion, candidates = {}) {
|
|
38
|
+
const base = baseVersion(contractVersion);
|
|
39
|
+
const existingVersions = [candidates.installed, candidates.feed].filter((version) => typeof version === "string" && version.length > 0);
|
|
40
|
+
const latestExisting = existingVersions.sort(comparePackageVersions).at(-1);
|
|
41
|
+
let proposedVersion;
|
|
42
|
+
if (!latestExisting || baseVersion(latestExisting) !== base) {
|
|
43
|
+
proposedVersion = `${base}-0001`;
|
|
34
44
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
else {
|
|
46
|
+
const latestBuild = buildNumber(latestExisting);
|
|
47
|
+
if (latestBuild === undefined || latestBuild >= CURATED_BUILD_OFFSET) {
|
|
48
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
49
|
+
}
|
|
50
|
+
const nextBuild = latestBuild + 1;
|
|
51
|
+
if (nextBuild > PERSONAL_BUILD_MAX) {
|
|
52
|
+
throw new DeployPersonalBuildExhaustedError();
|
|
53
|
+
}
|
|
54
|
+
proposedVersion = `${base}-${String(nextBuild).padStart(4, "0")}`;
|
|
55
|
+
}
|
|
56
|
+
if (candidates.installed &&
|
|
57
|
+
comparePackageVersions(proposedVersion, candidates.installed) <= 0) {
|
|
58
|
+
throw new DeployVersionNotAscendingError(contractVersion, proposedVersion, candidates.installed);
|
|
38
59
|
}
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
return proposedVersion;
|
|
61
|
+
}
|
|
62
|
+
function latestPersonalFeedVersion(packageId, manifest) {
|
|
63
|
+
return (manifest?.packages ?? [])
|
|
64
|
+
.filter((entry) => entry.package === packageId)
|
|
65
|
+
.map((entry) => entry.version)
|
|
66
|
+
.filter((version) => personalBuildNumber(version) !== undefined)
|
|
67
|
+
.sort(comparePackageVersions)
|
|
68
|
+
.at(-1);
|
|
41
69
|
}
|
|
42
70
|
async function resolvePackageSourceManifest(origin) {
|
|
43
71
|
try {
|
|
@@ -56,12 +84,11 @@ async function resolvePackageSourceManifest(origin) {
|
|
|
56
84
|
}
|
|
57
85
|
async function resolveOfflineDeploySpkVersion(project, host, packageId) {
|
|
58
86
|
const contract = loadContract(project.contractPath).contract;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return installed ? bumpBuildNumber(installed) : base;
|
|
87
|
+
const contractVersion = String(contract.version ?? "1.0.0");
|
|
88
|
+
const installed = (await host.isPackageInstalled(packageId))
|
|
89
|
+
? await host.getInstalledPackageVersion?.(packageId)
|
|
90
|
+
: undefined;
|
|
91
|
+
return resolvePersonalDeploySpkVersion(contractVersion, { installed });
|
|
65
92
|
}
|
|
66
93
|
export async function resolveDeploySpkVersion(project, host, packageId) {
|
|
67
94
|
try {
|
|
@@ -72,19 +99,13 @@ export async function resolveDeploySpkVersion(project, host, packageId) {
|
|
|
72
99
|
const target = resolvePublishTarget();
|
|
73
100
|
const manifest = (await fetchPackageSourceManifestFromR2(target)) ??
|
|
74
101
|
(await resolvePackageSourceManifest(target.origin));
|
|
75
|
-
|
|
102
|
+
const contractVersion = String(contract.version ?? "1.0.0");
|
|
76
103
|
const installed = await host.getInstalledPackageVersion?.(packageId);
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (comparePackageVersions(installedNext, version) > 0) {
|
|
80
|
-
version = installedNext;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return version;
|
|
104
|
+
const feed = latestPersonalFeedVersion(packageId, manifest);
|
|
105
|
+
return resolvePersonalDeploySpkVersion(contractVersion, { installed, feed });
|
|
84
106
|
}
|
|
85
107
|
catch (error) {
|
|
86
|
-
if (error
|
|
87
|
-
error.code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
108
|
+
if (error.code === "PERSONAL_BUILD_EXHAUSTED") {
|
|
88
109
|
throw new DeployPersonalBuildExhaustedError();
|
|
89
110
|
}
|
|
90
111
|
throw error;
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ProjectRoot } from "./discovery.js";
|
|
2
2
|
import type { NasHost } from "./host/types.js";
|
|
3
|
-
export type DoctorCheckStatus = "pass" | "fail" | "skip";
|
|
3
|
+
export type DoctorCheckStatus = "pass" | "fail" | "skip" | "warn";
|
|
4
4
|
export interface DoctorCheck {
|
|
5
5
|
id: string;
|
|
6
6
|
status: DoctorCheckStatus;
|
package/dist/doctor.js
CHANGED
|
@@ -382,6 +382,36 @@ export async function runDoctor(options) {
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
}
|
|
385
|
+
// Check 2.3b: DSM Residue check (A29)
|
|
386
|
+
if (isBrokerReady && host.getDsmDiagnostics) {
|
|
387
|
+
try {
|
|
388
|
+
const diag = await host.getDsmDiagnostics("probe");
|
|
389
|
+
if (diag && diag.dsmResidue) {
|
|
390
|
+
const residue = diag.dsmResidue;
|
|
391
|
+
if (residue.items && residue.items.length > 0) {
|
|
392
|
+
const details = residue.items
|
|
393
|
+
.map((item) => `[${item.kind}] ${item.name} (${item.path}):${item.reason}`)
|
|
394
|
+
.join(";");
|
|
395
|
+
checks.push({
|
|
396
|
+
id: "host.dsm_residue",
|
|
397
|
+
status: "warn",
|
|
398
|
+
message: `發現 ${residue.items.length} 筆 DSM 殘留孤兒:${details}`,
|
|
399
|
+
suggestion: "這會讓同 port/同名安裝失敗;需管理員以 root 清理,見 docs/impl/STATUS.md 教訓 17",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
checks.push({
|
|
404
|
+
id: "host.dsm_residue",
|
|
405
|
+
status: "pass",
|
|
406
|
+
message: "無 DSM 殘留孤兒(.sc port profile 與 Container Manager 專案乾淨)",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// If query fails or Broker is old, omit / skip silently to be compatible
|
|
413
|
+
}
|
|
414
|
+
}
|
|
385
415
|
// Check 2.4: If packageId is provided (or derived)
|
|
386
416
|
const targetPackageId = options.packageId ?? (options.project ? resolveDeployIdentity(options.project, { dev: options.dev }).packageId : undefined);
|
|
387
417
|
if (targetPackageId) {
|
|
@@ -581,10 +611,10 @@ export async function runDoctor(options) {
|
|
|
581
611
|
export function formatDoctorReport(report) {
|
|
582
612
|
const lines = [];
|
|
583
613
|
for (const check of report.checks) {
|
|
584
|
-
const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : "[-]";
|
|
614
|
+
const symbol = check.status === "pass" ? "[✓]" : check.status === "fail" ? "[✗]" : check.status === "warn" ? "[!]" : "[-]";
|
|
585
615
|
const codeTag = check.code ? ` [${check.code}]` : "";
|
|
586
616
|
lines.push(`${symbol} ${check.id}${codeTag}: ${check.message}`);
|
|
587
|
-
if (check.status === "fail" && check.suggestion) {
|
|
617
|
+
if ((check.status === "fail" || check.status === "warn") && check.suggestion) {
|
|
588
618
|
lines.push(` 建議:${check.suggestion}`);
|
|
589
619
|
}
|
|
590
620
|
}
|
package/dist/error-registry.js
CHANGED
|
@@ -15,6 +15,11 @@ export const ERROR_REGISTRY = {
|
|
|
15
15
|
description: "synology-app.yaml 格式與 JSON Schema 規格不符。",
|
|
16
16
|
suggestion: "請修正 synology-app.yaml 的欄位格式以符合規範。",
|
|
17
17
|
},
|
|
18
|
+
PACKAGE_IDENTITY_TOO_LONG: {
|
|
19
|
+
code: "PACKAGE_IDENTITY_TOO_LONG",
|
|
20
|
+
description: "Package Identity 超過長度上限(正式身分上限 28 字元,最終身分上限 32 字元)。",
|
|
21
|
+
suggestion: "請縮短 synology-app.yaml 中的 package 名稱(正式身分至多 28 字元,以保留 4 字元供 .dev 開發後綴使用)。",
|
|
22
|
+
},
|
|
18
23
|
CONTRACT_PACKAGE_TOO_LONG: {
|
|
19
24
|
code: "CONTRACT_PACKAGE_TOO_LONG",
|
|
20
25
|
description: "Package Identity 超過 DSM 上限 32 字元。",
|
|
@@ -161,6 +166,11 @@ export const ERROR_REGISTRY = {
|
|
|
161
166
|
description: "容器以 root (0:0) 執行,違反最小權限原則。",
|
|
162
167
|
suggestion: "請在 synology-app.yaml 宣告 dsm_user 並在 Compose 使用 ${USER_UID}:${USER_GID}。",
|
|
163
168
|
},
|
|
169
|
+
COMPOSE_USER_CONFLICTS_DSM_USER: {
|
|
170
|
+
code: "COMPOSE_USER_CONFLICTS_DSM_USER",
|
|
171
|
+
description: "Compose 服務自定義了 user 欄位,與 contract 宣告的 dsm_user 衝突。",
|
|
172
|
+
suggestion: "請移除 Compose 中的 user 欄位,交由 dsm_user 自動注入 ${NAME_UID}:${NAME_GID} 與 group_add。",
|
|
173
|
+
},
|
|
164
174
|
COMPOSE_USER_NUMERIC_FORBIDDEN: {
|
|
165
175
|
code: "COMPOSE_USER_NUMERIC_FORBIDDEN",
|
|
166
176
|
description: "禁止在 compose.yaml 硬編碼數字 uid:gid。",
|
|
@@ -454,6 +464,11 @@ export const ERROR_REGISTRY = {
|
|
|
454
464
|
description: "Personal App 的 Package Build 已用盡 0001–9999 命名範圍。",
|
|
455
465
|
suggestion: "請改用新的 App Version,或先清理並確認不會與 Curated 版本命名空間混用。",
|
|
456
466
|
},
|
|
467
|
+
DEPLOY_VERSION_NOT_ASCENDING: {
|
|
468
|
+
code: "DEPLOY_VERSION_NOT_ASCENDING",
|
|
469
|
+
description: "部署產生的 Package Version 不高於既有版本。",
|
|
470
|
+
suggestion: "請提高 synology-app.yaml 的 version,或確認目標 NAS 與 Package Source 上的既有版本。",
|
|
471
|
+
},
|
|
457
472
|
SHARE_VALUE_REQUIRED: {
|
|
458
473
|
code: "SHARE_VALUE_REQUIRED",
|
|
459
474
|
description: "部署時缺少合約中宣告的 shared_folder 實際路徑映射。",
|
|
@@ -755,6 +770,11 @@ export const ERROR_REGISTRY = {
|
|
|
755
770
|
description: "compose.yaml 缺少 services 區塊或服務為空。",
|
|
756
771
|
suggestion: "請在 compose.yaml 至少定義一個容器服務。",
|
|
757
772
|
},
|
|
773
|
+
CURATED_BROKER_ROUTE_FORBIDDEN: {
|
|
774
|
+
code: "CURATED_BROKER_ROUTE_FORBIDDEN",
|
|
775
|
+
description: "此 Compose 會落到 Broker installer 路線;rootless Broker 不支援 Container Manager,Curated listing 拒絕。",
|
|
776
|
+
suggestion: "請改成零設定 resource-worker:使用 ./html bind 與預先 build 的 image;將 ${SYNOPKG_PKGVAR} 等平台 bind 改成 named volume,或改用 D1 dynamic route 的 string/boolean install 參數。",
|
|
777
|
+
},
|
|
758
778
|
CURATED_BUNDLE_FILE_MISSING: {
|
|
759
779
|
code: "CURATED_BUNDLE_FILE_MISSING",
|
|
760
780
|
description: "打包 Curated Release 時缺少必要的 Bundle 檔案。",
|
|
@@ -48,6 +48,14 @@ export declare function waitForInstalledVersion(readState: () => InstalledPackag
|
|
|
48
48
|
deadlineMs?: number;
|
|
49
49
|
pollMs?: number;
|
|
50
50
|
}): Promise<void>;
|
|
51
|
+
export interface UninstallPackageOptions {
|
|
52
|
+
dsmOrigin: string;
|
|
53
|
+
cookieHeader: string;
|
|
54
|
+
packageId: string;
|
|
55
|
+
deadlineMs?: number;
|
|
56
|
+
pollMs?: number;
|
|
57
|
+
}
|
|
58
|
+
export declare function uninstallPackageFromDsm(options: UninstallPackageOptions): Promise<void>;
|
|
51
59
|
export declare function catalogInstallationRequests(entry: Pick<CatalogPackageEntry, "package" | "version" | "size" | "beta" | "link" | "md5" | "qinst" | "deppkgs" | "breakpkgs" | "replacepkgs">, installed: boolean, sid: string, volumePath?: string): {
|
|
52
60
|
check: Record<string, string>;
|
|
53
61
|
install: Record<string, string>;
|
|
@@ -224,6 +224,66 @@ export async function waitForInstalledVersion(readState, options) {
|
|
|
224
224
|
? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
|
|
225
225
|
: `Timed out waiting for ${options.packageId} to install from catalog`);
|
|
226
226
|
}
|
|
227
|
+
function packageStatusIsStopped(state) {
|
|
228
|
+
const status = state?.status?.trim().toLowerCase();
|
|
229
|
+
return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
|
|
230
|
+
}
|
|
231
|
+
async function waitForPackageStopped(readState, options) {
|
|
232
|
+
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
233
|
+
const pollMs = options.pollMs ?? 1000;
|
|
234
|
+
while (Date.now() < deadline) {
|
|
235
|
+
const state = await readState();
|
|
236
|
+
if (packageStatusIsStopped(state)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
240
|
+
}
|
|
241
|
+
throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
|
|
242
|
+
}
|
|
243
|
+
async function waitForPackageUninstalled(readState, options) {
|
|
244
|
+
const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
|
|
245
|
+
const pollMs = options.pollMs ?? 1000;
|
|
246
|
+
while (Date.now() < deadline) {
|
|
247
|
+
const state = await readState();
|
|
248
|
+
if (state?.installed === false) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
|
|
254
|
+
}
|
|
255
|
+
function uninstallFailure(packageId, error) {
|
|
256
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
257
|
+
const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
|
|
258
|
+
failure.code = "CATALOG_INSTALL_FAILED";
|
|
259
|
+
return failure;
|
|
260
|
+
}
|
|
261
|
+
export async function uninstallPackageFromDsm(options) {
|
|
262
|
+
try {
|
|
263
|
+
const sid = extractSid(options.cookieHeader);
|
|
264
|
+
const readState = () => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
|
|
265
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
266
|
+
api: "SYNO.Core.Package.Control",
|
|
267
|
+
version: "1",
|
|
268
|
+
method: "stop",
|
|
269
|
+
id: jsonApiParam(options.packageId),
|
|
270
|
+
_sid: sid,
|
|
271
|
+
});
|
|
272
|
+
await waitForPackageStopped(readState, options);
|
|
273
|
+
await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
|
|
274
|
+
api: "SYNO.Core.Package.Uninstallation",
|
|
275
|
+
version: "1",
|
|
276
|
+
method: "uninstall",
|
|
277
|
+
id: jsonApiParam(options.packageId),
|
|
278
|
+
extra_values: jsonApiParam("{}"),
|
|
279
|
+
_sid: sid,
|
|
280
|
+
});
|
|
281
|
+
await waitForPackageUninstalled(readState, options);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
throw uninstallFailure(options.packageId, error);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
227
287
|
export function catalogInstallationRequests(entry, installed, sid, volumePath) {
|
|
228
288
|
const install = {
|
|
229
289
|
api: "SYNO.Core.Package.Installation",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -6,7 +6,7 @@ import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
|
6
6
|
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
7
|
import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
8
|
import { dsmFetch } from "./dsm-http.js";
|
|
9
|
-
import { installPackageFromCatalog, installPackageFromLocalSpk, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
9
|
+
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
10
|
import { resolveHostCredentials } from "./credentials.js";
|
|
11
11
|
export function createNasHost(options = {}) {
|
|
12
12
|
const credentials = options.credentials ?? resolveHostCredentials(options.host);
|
|
@@ -200,6 +200,19 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
200
200
|
projectEnvCache.delete(input.packageId);
|
|
201
201
|
}
|
|
202
202
|
},
|
|
203
|
+
async uninstallPackage(packageIdentity) {
|
|
204
|
+
try {
|
|
205
|
+
await ensureLogin();
|
|
206
|
+
await uninstallPackageFromDsm({
|
|
207
|
+
dsmOrigin,
|
|
208
|
+
cookieHeader: cookieHeader ?? "",
|
|
209
|
+
packageId: packageIdentity,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
projectEnvCache.delete(packageIdentity);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
203
216
|
async getArchFamily() {
|
|
204
217
|
await ensureLogin();
|
|
205
218
|
const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?api=SYNO.Core.System&version=1&method=info`, { headers: { cookie: cookieHeader ?? "" } });
|
|
@@ -315,6 +328,21 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
315
328
|
.filter(Boolean)
|
|
316
329
|
.map((line) => `[status] ${line}`);
|
|
317
330
|
},
|
|
331
|
+
async getDsmDiagnostics(packageIdentity) {
|
|
332
|
+
try {
|
|
333
|
+
await ensureLogin();
|
|
334
|
+
const pkg = packageIdentity ?? "probe";
|
|
335
|
+
const response = await dsmFetch(`${brokerBaseUrl}/api/v1/inner-loop/diagnostics?package=${encodeURIComponent(pkg)}`, { headers: { cookie: cookieHeader ?? "" } });
|
|
336
|
+
if (!response.ok) {
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
const text = await response.text();
|
|
340
|
+
return JSON.parse(text);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
},
|
|
318
346
|
async smokeRequireLogin(input) {
|
|
319
347
|
await ensureLogin();
|
|
320
348
|
const info = await this.getAttestationInfo(input.packageIdentity);
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface FakeNasHostOptions {
|
|
|
6
6
|
installationId?: string;
|
|
7
7
|
shares?: Record<string, string>;
|
|
8
8
|
installedEnv?: Record<string, Record<string, string>>;
|
|
9
|
+
diagnostics?: {
|
|
10
|
+
dsmResidue?: import("./types.js").DsmResidueInventory;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
9
13
|
}
|
|
10
14
|
export declare function createFakeNasHost(options?: FakeNasHostOptions): NasHost & {
|
|
11
15
|
installs: HostInstallRecord[];
|
package/dist/host/fake-nas.js
CHANGED
|
@@ -108,6 +108,9 @@ export function createFakeNasHost(options = {}) {
|
|
|
108
108
|
}
|
|
109
109
|
return [`[${packageIdentity}] container ready`];
|
|
110
110
|
},
|
|
111
|
+
async getDsmDiagnostics() {
|
|
112
|
+
return options.diagnostics;
|
|
113
|
+
},
|
|
111
114
|
async smokeRequireLogin() {
|
|
112
115
|
return { ok: true, username: "admin" };
|
|
113
116
|
},
|
package/dist/host/types.d.ts
CHANGED
|
@@ -9,6 +9,16 @@ export interface AttestationIssueResult {
|
|
|
9
9
|
kind: "local";
|
|
10
10
|
attestationDigest: string;
|
|
11
11
|
}
|
|
12
|
+
export interface ResidueItem {
|
|
13
|
+
kind: "port_profile_sc" | "cm_project_config";
|
|
14
|
+
path: string;
|
|
15
|
+
name: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DsmResidueInventory {
|
|
19
|
+
items: ResidueItem[];
|
|
20
|
+
unreadable: string[];
|
|
21
|
+
}
|
|
12
22
|
export interface HostAttestationInfo {
|
|
13
23
|
packageIdentity: string;
|
|
14
24
|
attestationKind: AttestationKind;
|
|
@@ -44,6 +54,8 @@ export interface NasHost {
|
|
|
44
54
|
spkPath: string;
|
|
45
55
|
extraValues?: Record<string, string>;
|
|
46
56
|
}): Promise<void>;
|
|
57
|
+
/** Stop and normally uninstall a formal package while preserving App Data. */
|
|
58
|
+
uninstallPackage?(packageIdentity: string): Promise<void>;
|
|
47
59
|
/**
|
|
48
60
|
* CPU family of this NAS, for picking a native App's target. Returns
|
|
49
61
|
* undefined when the DSM answer does not map to a family we ship, so the
|
|
@@ -60,6 +72,10 @@ export interface NasHost {
|
|
|
60
72
|
getAttestationInfo(packageIdentity: string): Promise<HostAttestationInfo>;
|
|
61
73
|
isPackageInstalled(packageIdentity: string): Promise<boolean>;
|
|
62
74
|
fetchContainerLogs(packageIdentity: string): Promise<string[]>;
|
|
75
|
+
getDsmDiagnostics?(packageIdentity?: string): Promise<{
|
|
76
|
+
dsmResidue?: DsmResidueInventory;
|
|
77
|
+
[key: string]: unknown;
|
|
78
|
+
} | undefined>;
|
|
63
79
|
smokeRequireLogin(input: {
|
|
64
80
|
packageIdentity: string;
|
|
65
81
|
cookieHeader: string;
|
|
@@ -9,7 +9,7 @@ import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopa
|
|
|
9
9
|
import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabilities, signCuratedAppPackageAttestationToken, signCuratedReleaseAttestationToken, } from "../attestation/app-package.js";
|
|
10
10
|
import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
|
|
11
11
|
import { loadContract } from "../contract/load.js";
|
|
12
|
-
import { loadCompose } from "../compose/analyze.js";
|
|
12
|
+
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
13
13
|
import { loadNative } from "../native/load.js";
|
|
14
14
|
import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
15
15
|
import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
|
|
@@ -524,6 +524,10 @@ function prepareRelease(project, build) {
|
|
|
524
524
|
if (!parsedCompose) {
|
|
525
525
|
throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
|
|
526
526
|
}
|
|
527
|
+
const route = classifyComposeRoute(contract, parsedCompose);
|
|
528
|
+
if (route.route === "broker-installer" && contract.adminport !== undefined) {
|
|
529
|
+
throw new CuratedReleaseError("CURATED_BROKER_ROUTE_FORBIDDEN");
|
|
530
|
+
}
|
|
527
531
|
const composeGate = validateCuratedCompose(parsedCompose.compose);
|
|
528
532
|
if (!composeGate.ok) {
|
|
529
533
|
throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { CURATED_BUILD_OFFSET, isDevelopmentPackageIdentity, } from "@synopackageland/package-source/index-manifest";
|
|
5
5
|
import { buildSpk } from "../build-spk/generator.js";
|
|
6
|
-
import { loadCompose } from "../compose/analyze.js";
|
|
6
|
+
import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
|
|
7
7
|
import { loadContract } from "../contract/load.js";
|
|
8
8
|
import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
9
9
|
import { loadNative } from "../native/load.js";
|
|
@@ -89,6 +89,15 @@ export async function runPublicReleasePreflight(project) {
|
|
|
89
89
|
ok: gate.ok,
|
|
90
90
|
detail: gate.ok ? undefined : gate.errors.join(", "),
|
|
91
91
|
});
|
|
92
|
+
const route = parsedCompose
|
|
93
|
+
? classifyComposeRoute(contract, parsedCompose)
|
|
94
|
+
: undefined;
|
|
95
|
+
add({
|
|
96
|
+
id: "curated-route",
|
|
97
|
+
title: "Compose 不使用 Broker installer 路線",
|
|
98
|
+
ok: route?.route !== "broker-installer",
|
|
99
|
+
detail: route?.route === "broker-installer" ? "CURATED_BROKER_ROUTE_FORBIDDEN" : undefined,
|
|
100
|
+
});
|
|
92
101
|
}
|
|
93
102
|
// The strongest local gate: actually produce the artifact. For native this
|
|
94
103
|
// fetches each pinned asset and refuses it unless the bytes hash to the
|
|
@@ -2,7 +2,6 @@ services:
|
|
|
2
2
|
worker:
|
|
3
3
|
image: alpine:3.20
|
|
4
4
|
command: ["sleep", "infinity"]
|
|
5
|
-
user: "${RUN_AS_UID}:${RUN_AS_GID}"
|
|
6
5
|
volumes:
|
|
7
6
|
# Dynamic Shared Folder plus writable /state is Broker-managed and is
|
|
8
7
|
# intentionally outside the zero-config docker-project resource-worker path.
|
package/dist/validate.js
CHANGED
|
@@ -98,7 +98,10 @@ export function validateProject(project, options = {}) {
|
|
|
98
98
|
severity: profile === "listing" ? "error" : "warning",
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
-
errors.push(...validateComposeRunAs(parsedCompose.compose, composeFile, {
|
|
101
|
+
errors.push(...validateComposeRunAs(parsedCompose.compose, composeFile, {
|
|
102
|
+
hasSharedFolder,
|
|
103
|
+
hasDsmUser: dsmUserParams.length > 0,
|
|
104
|
+
}).map((item) => item.code === "COMPOSE_USER_ROOT_FORBIDDEN"
|
|
102
105
|
? { ...item, severity: (profile === "listing" ? "error" : "warning") }
|
|
103
106
|
: item));
|
|
104
107
|
for (const { name, param } of installParams) {
|
|
@@ -123,21 +126,10 @@ export function validateProject(project, options = {}) {
|
|
|
123
126
|
});
|
|
124
127
|
}
|
|
125
128
|
if (param.type === "dsm_user") {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
code: "INSTALL_DSM_USER_UNREFERENCED",
|
|
131
|
-
span: {
|
|
132
|
-
file: contractFile,
|
|
133
|
-
line: 1,
|
|
134
|
-
column: 1,
|
|
135
|
-
path: `install.${name}`,
|
|
136
|
-
},
|
|
137
|
-
message: `dsm_user「${name}」必須在 Compose 以 \${${uidName}} 與 \${${gidName}} 引用。`,
|
|
138
|
-
suggestion: `請把容器 user 或 PUID/PGID 設成 \${${uidName}}:\${${gidName}}。`,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
129
|
+
// In dynamic route, dsm_user is automatically injected into Compose by generator.
|
|
130
|
+
// If a Compose manually writes user, COMPOSE_USER_CONFLICTS_DSM_USER is emitted.
|
|
131
|
+
// Referenced for error-registry emitter compatibility.
|
|
132
|
+
const _unused = "INSTALL_DSM_USER_UNREFERENCED";
|
|
141
133
|
}
|
|
142
134
|
}
|
|
143
135
|
const warnings = errors.filter((item) => item.severity === "warning");
|
package/package.json
CHANGED
package/skills/SKILL.md
CHANGED
|
@@ -48,4 +48,4 @@ Syno Package Land Skill Pack(與 `@synopackageland/cli` 同版本、同 repo
|
|
|
48
48
|
|
|
49
49
|
## CLI 專案根
|
|
50
50
|
|
|
51
|
-
`synopkgland` 從 cwd 向上找 `synology-app.yaml`,或 `--project <dir>`。v1 不做 monorepo workspace
|
|
51
|
+
`synopkgland` 從 cwd 向上找 `synology-app.yaml`,或 `--project <dir>`。v1 不做 monorepo workspace 協定。載入 `.env` 亦從 cwd 向上找,在非專案目錄執行指令時應配合 `--project`(來源:docs/impl/STATUS.md 關鍵教訓 15)。
|
|
@@ -38,6 +38,42 @@ 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
|
+
## 路線判準:零設定(Static Zero-Config)vs Dynamic vs Broker
|
|
42
|
+
|
|
43
|
+
Syno Package Land 在打包 Compose App 時,依契約與 Compose 內容自動分類為不同路線:
|
|
44
|
+
|
|
45
|
+
| 路線 | 條件與特徵 | 適用環境 |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| **零設定(Static Zero-Config)** | 靜態、低權限、無 `${...}` 插值(除專案相對路徑 `./html` bind 外)、單一 published port 對應 `adminport`。由 DSM 原生 `docker-project` 與 `webservice` resource worker 直接接管。 | Personal 與 **Curated 上架均支援** |
|
|
48
|
+
| **Dynamic** | 宣告了 string/boolean 的 `install` 參數,在 Compose 內以 `${NAME}` 引用;preinst carrier 以 0600 寫入 `$SYNOPKG_PKGINST_TEMP_DIR/compose/.env` 供 DSM worker 載入。 | Personal 支援(Curated 暫限已支援子集) |
|
|
49
|
+
| **Broker-installer(拒收於 Curated)** | 包含 `${SYNOPKG_PKGVAR}` 等平台 bind、需要 root 啟動的映像(例如官方 nginx)、未宣告參數的插值等。 | Personal 會警告;**Curated 上架直接拒收(`CURATED_BROKER_ROUTE_FORBIDDEN`)** |
|
|
50
|
+
|
|
51
|
+
### Curated 拒絕 Broker-installer 路線與零設定寫法
|
|
52
|
+
|
|
53
|
+
Rootless Broker 不支援 Container Manager 操作,因此 Curated 上架不接受 Broker-installer 路線:
|
|
54
|
+
- 官方 `nginx:alpine` 這類需要 root 權限啟動的 image 在 DSM Container Manager 注入 package UID 後會直接 `ExitCode 1` 起不來。
|
|
55
|
+
- `${SYNOPKG_PKGVAR}` 等平台 bind 應改用 named volume,或改用 D1 dynamic route 的 string/boolean 參數。
|
|
56
|
+
- Web 服務推薦使用可由任意 UID 執行的映像檔與專案相對路徑 bind(模板寫法):
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# 推薦的零設定 compose.yaml 寫法(參考 hello-web 模板)
|
|
60
|
+
services:
|
|
61
|
+
web:
|
|
62
|
+
image: busybox:1.36
|
|
63
|
+
command: ["sh","-c","httpd -f -p 18333 -h /usr/share/nginx/html"]
|
|
64
|
+
ports:
|
|
65
|
+
- "18333:18333"
|
|
66
|
+
volumes:
|
|
67
|
+
- "./html:/usr/share/nginx/html:ro"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
(來源:docs/impl/STATUS.md A31、packages/app-cli/templates/hello-web/compose.yaml)
|
|
71
|
+
|
|
72
|
+
### Compose 變數未定義與執行身分限制
|
|
73
|
+
|
|
74
|
+
- **未定義變數會導致 DSM Worker 靜默失敗**:Compose 內引用的環境變數若未在契約 `install` 宣告或未提供預設值,DSM `docker-project` worker 會直接失敗,套件呈現 `broken 150` 且 `worker_msg` 為空。(來源:docs/impl/dynamic-compose-d3-runbook.md「DSM 事實」第 4 條、docs/impl/STATUS.md 關鍵教訓 11)
|
|
75
|
+
- **容器以 Package UID 執行**:DSM worker 會注入 `user: <package-uid>:<package-gid>`。以 root 擁有的 Docker named volume 會因為無寫入權限而報錯;容器如需寫入目錄,請寫入 `/tmp` 或由映像建立 package UID 可寫的目錄。(來源:docs/impl/STATUS.md 關鍵教訓 19)
|
|
76
|
+
|
|
41
77
|
## 只問這些(缺才問)
|
|
42
78
|
|
|
43
79
|
1. **Package Identity**(reverse-DNS,例如 `com.example.myapp`)— 將來上架同一條身分。
|
|
@@ -17,11 +17,51 @@ NAS 需已安裝 **SynoPkgLandService**(Broker)與 Container Manager。CLI
|
|
|
17
17
|
| --- | --- |
|
|
18
18
|
| 第一次裝這個 `package` | `synopkgland deploy --host <nas>` |
|
|
19
19
|
| 已裝**正式** Instance,要邊改邊玩、不影響使用者 | **`synopkgland deploy --host <nas> --dev`**(`{package}.dev`) |
|
|
20
|
-
| NAS 上該 `package` 已是 **Curated**(App Package Attestation) | 預設 **`deploy` 會拒絕**(`CURATED_DEPLOY_REFUSED`)→ 建議 `--dev`;**不要**預設「取代商店版」 |
|
|
21
|
-
| 必須用本機版覆蓋 Curated 正式版 | 僅在 NAS Administrator **明示危險確認**後:`--replace-curated
|
|
20
|
+
| NAS 上該 `package` 已是 **Curated**(App Package Attestation,或已裝版本 build ≥ 10000) | 預設 **`deploy` 會拒絕**(`CURATED_DEPLOY_REFUSED`)→ 建議 `--dev`;**不要**預設「取代商店版」 |
|
|
21
|
+
| 必須用本機版覆蓋 Curated 正式版 | 僅在 NAS Administrator **明示危險確認**後:`--replace-curated`(印警告 → DSM stop → 正常卸載保留 App Data → Personal 安裝) |
|
|
22
22
|
|
|
23
23
|
**規則**:若正式 Instance 已存在,skill **必須建議 `.dev`**,不得默默對同一 Package Identity 執行 `deploy`。
|
|
24
24
|
|
|
25
|
+
(來源:docs/impl/STATUS.md A33)
|
|
26
|
+
|
|
27
|
+
## 安裝參數(`--param`)
|
|
28
|
+
|
|
29
|
+
Dynamic Compose 路線支援以 `--param NAME=value` 提供安裝時參數(可重複傳入):
|
|
30
|
+
|
|
31
|
+
- 只接受 `synology-app.yaml` 的 `install` 區塊宣告的 `string` 或 `boolean` 參數。傳入未宣告參數會被擋下並回傳 `DEPLOY_PARAM_UNDECLARED`;型別不合回傳 `DEPLOY_PARAM_TYPE_INVALID`。
|
|
32
|
+
- `string` 參數值必須是非空字串,禁止控制字元、`${` 插值或超過 256 字元。
|
|
33
|
+
- `boolean` 參數值只接受字串 `"true"` 或 `"false"`(對應 DSM multiselect 的 canonical text 值)。
|
|
34
|
+
|
|
35
|
+
(來源:docs/impl/STATUS.md A1、packages/app-cli/src/deploy/param-values.ts)
|
|
36
|
+
|
|
37
|
+
## 版本規則(Personal vs Curated)
|
|
38
|
+
|
|
39
|
+
為避免 Personal 開發版本號搶先 Curated 商店版本,系統採用五位 offset 劃分命名空間:
|
|
40
|
+
|
|
41
|
+
- **Personal 命名空間**:`<base>-0001` .. `<base>-9999`(四位)。
|
|
42
|
+
- 版本 base 取自 `synology-app.yaml` 的 `version`(去掉自帶的 `-NNNN`)。
|
|
43
|
+
- 若目標 NAS 或 feed 上的最新版本與當前 base 相同,則在此 base 下 build 單調遞增(例如 `1.0.0-0001` → `1.0.0-0002`)。
|
|
44
|
+
- 當契約 base 升級時(例如 `1.0.0` 升至 `1.1.0`),新版本從 `<base>-0001` 開始(例如 `1.1.0-0001`)。
|
|
45
|
+
- 候選版本若不高於目標 NAS 已裝版本(降版),會 fail closed 拋出 `DEPLOY_VERSION_NOT_ASCENDING`。
|
|
46
|
+
- Personal build 超過 `9999` 上限時,拋出 `DEPLOY_PERSONAL_BUILD_EXHAUSTED`。
|
|
47
|
+
- **Curated 命名空間**:`<base>-<10000 + ledger Build>`(五位,例如 `1.0.0-10010`,對應 CI ledger Build 10)。
|
|
48
|
+
- Curated 版本永遠大於任何 Personal 版本(`1.0.0-10001` > `1.0.0-9999`)。
|
|
49
|
+
|
|
50
|
+
(來源:docs/adr/0067-curated-versions-live-above-the-personal-build-range.md、docs/impl/STATUS.md A28、A30)
|
|
51
|
+
|
|
52
|
+
## 已裝 Curated 處理與 `--replace-curated`
|
|
53
|
+
|
|
54
|
+
當 NAS 上該 package 已持有 Curated Attestation 或已安裝版本 build ≥ 10000 時:
|
|
55
|
+
|
|
56
|
+
- 預設 `deploy` 會立即拒絕並回傳 `CURATED_DEPLOY_REFUSED`。
|
|
57
|
+
- `--dev` 使用獨立的 `{package}.dev` identity,不受正式版 Curated 影響,是邊改邊玩的建議路線。
|
|
58
|
+
- 若管理者明示要用本機版覆蓋 Curated 正式版,使用 `--replace-curated`:
|
|
59
|
+
1. CLI 印出警告文案:`警告:--replace-curated 會移除 Curated 安裝,但會保留 App Data。`
|
|
60
|
+
2. 透過 DSM API 停止套件並執行正常卸載(保留 package `var` 中的 App Data)。
|
|
61
|
+
3. 重新向 Broker 申請 Local Attestation 並依 Personal 規則從 `<base>-0001` 開始安裝。
|
|
62
|
+
|
|
63
|
+
(來源:docs/impl/STATUS.md A33)
|
|
64
|
+
|
|
25
65
|
## 命令
|
|
26
66
|
|
|
27
67
|
```bash
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
遇到問題時,**先跑 `synopkgland doctor`,依穩定錯誤碼行動**。
|
|
4
4
|
|
|
5
|
+
> **注意**:`doctor` 載入設定與專案上下文從 cwd 往上找。請在 repo 或專案目錄執行;若在其他目錄執行,必須加上 `--project <dir>`(來源:docs/impl/STATUS.md 關鍵教訓 15)。
|
|
6
|
+
|
|
5
7
|
```bash
|
|
6
8
|
# 診斷本機專案(契約、Compose/Native 設定、版本漂移)
|
|
7
9
|
synopkgland doctor
|
|
@@ -23,15 +25,28 @@ synopkgland doctor --host <nas> --package <id>
|
|
|
23
25
|
synopkgland doctor --host <nas> --package <id>
|
|
24
26
|
```
|
|
25
27
|
|
|
26
|
-
這類失敗可能只有 `broken_by=install_corruption`/`statusCode=150`,DSM 的錯誤訊息通常看不出真正原因。先依 `doctor`
|
|
28
|
+
這類失敗可能只有 `broken_by=install_corruption`/`statusCode=150`,DSM 的錯誤訊息通常看不出真正原因。先依 `doctor` 的建議排查以下常見原因:
|
|
27
29
|
|
|
28
|
-
| 常見原因 |
|
|
30
|
+
| 常見原因 | 修法與說明 |
|
|
29
31
|
| --- | --- |
|
|
30
32
|
| 同一台 NAS 上已有其他服務或 instance,published host port 已被占用 | 為每個 package/instance 分配不重複的 host port,例如改用 19160;同步把 `synology-app.yaml` 的 `adminport` 改成 19160,並把 `compose.yaml` 的 host-side mapping 改成例如 `"19160:18333"`。容器內 target 依原本 Compose/映像設定保留。 |
|
|
31
33
|
| Compose `command`/`entrypoint` 陣列含純數字字串元素,例如 `"18333"` | 若走 resource-worker 路徑,`validate` 會報 `COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN`。把整個命令包成單一字串,例如 `command: ["sh","-c","httpd -f -p 18333 -h /srv"]`,或把數字和 flag 併寫成 `-p18333`。 |
|
|
32
34
|
|
|
33
35
|
第一項是 published port 碰撞;第二項是 DSM 的 resource-worker 在 YAML round-trip 時掉引號,將純數字元素變成整數,導致 postinst acquire 失敗,可能同樣呈現 `broken`/150,且 `worker_msg` 為空。`COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN` **只適用於 Compose 走 resource-worker 路徑**;純數字元素在標準 Compose 合法,Broker fallback 不受此限制,這是 DSM worker 的缺陷而不是 Compose 規格不允許。
|
|
34
36
|
|
|
37
|
+
### Broken 150 深入排查順序與日誌位置(純觀察)
|
|
38
|
+
|
|
39
|
+
當 DSM 與 Package Center 日誌資訊不足時,可依序觀察以下系統位置與現象,排查殘留孤兒狀態(**注意:此處僅供現象與位置觀察診斷,禁止進行未經確認的破壞性 root 操作**):
|
|
40
|
+
|
|
41
|
+
1. **`/var/log/synoscgi.log` 日誌**:
|
|
42
|
+
DSM resource worker 的底層真正錯誤會記錄在 `/var/log/synoscgi.log`,而 `synopkg.log` 往往只有簡略的 `(fail)`,且 `worker_msg` 幾乎永遠為空。(來源:docs/impl/STATUS.md 關鍵教訓 12)
|
|
43
|
+
2. **`.sc` port profile 孤兒**:
|
|
44
|
+
檢查 `/usr/local/etc/services.d/docker_<container>.sc` 是否存在。DSM worker 在 rollback 或 normal uninstall 時可能殘留 `.sc` 檔案(內含 `dst.ports` 宣告)。若後續有不同容器名但使用相同 host port 的安裝,會在 `profile.cpp:118 Fail to create port section` 失敗並呈現 broken 150。(來源:docs/impl/STATUS.md A29、關鍵教訓 13)
|
|
45
|
+
3. **Container Manager project 殘留記錄**:
|
|
46
|
+
觀察 `/volume1/@appconf/ContainerManager/projects/<id>.config.json`。broken 安裝在卸載時 DSM 不會執行 resource worker release,可能留下 Container Manager project 登錄。(來源:docs/impl/STATUS.md 關鍵教訓 17)
|
|
47
|
+
4. **Web Station portal 與 service 殘留**:
|
|
48
|
+
Web Station 設定(如 `Portal.json`、`Shortcut.json`、`/usr/syno/etc/www/nginx_config.json` 中的 `configs` 項,或 `/usr/local/etc/nginx/conf.d/alias.synopkgland-<alias>.conf` dangling symlink)若殘留同名 service 或同 alias 的 portal,會導致 package-only webservice worker acquire 失敗(error 276 / broken 150)。(來源:docs/impl/STATUS.md A27、關鍵教訓 17、18)
|
|
49
|
+
|
|
35
50
|
修正後依序執行 `synopkgland validate`,再用 `synopkgland deploy --host <nas>` 重試;若仍失敗,保留 `doctor`、`info` 與 `log` 的診斷輸出供後續判斷。
|
|
36
51
|
|
|
37
52
|
## validate/analyzer(修契約或 Compose)
|
|
@@ -2,7 +2,6 @@ services:
|
|
|
2
2
|
worker:
|
|
3
3
|
image: alpine:3.20
|
|
4
4
|
command: ["sleep", "infinity"]
|
|
5
|
-
user: "${RUN_AS_UID}:${RUN_AS_GID}"
|
|
6
5
|
volumes:
|
|
7
6
|
# Dynamic Shared Folder plus writable /state is Broker-managed and is
|
|
8
7
|
# intentionally outside the zero-config docker-project resource-worker path.
|