@synopackageland/cli 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +26 -16
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -846
- package/dist/build-spk/resource-worker.d.ts +2 -14
- package/dist/build-spk/resource-worker.js +3 -166
- package/dist/cli.js +208 -56
- package/dist/compose/analyze.d.ts +3 -61
- package/dist/compose/analyze.js +35 -776
- package/dist/compose/env.d.ts +5 -3
- package/dist/compose/env.js +17 -25
- package/dist/compose/runner.js +23 -6
- package/dist/contract/web-launch.d.ts +1 -12
- package/dist/contract/web-launch.js +10 -74
- package/dist/ctl/cli.d.ts +36 -0
- package/dist/ctl/cli.js +218 -0
- package/dist/ctl/descriptor.d.ts +81 -0
- package/dist/ctl/descriptor.js +93 -0
- package/dist/ctl/dsm-auth.d.ts +10 -0
- package/dist/ctl/dsm-auth.js +30 -0
- package/dist/ctl/errors.d.ts +17 -0
- package/dist/ctl/errors.js +32 -0
- package/dist/ctl/flows.d.ts +11 -0
- package/dist/ctl/flows.js +52 -0
- package/dist/ctl/input.d.ts +20 -0
- package/dist/ctl/input.js +128 -0
- package/dist/ctl/json.d.ts +2 -0
- package/dist/ctl/json.js +38 -0
- package/dist/ctl/redaction.d.ts +15 -0
- package/dist/ctl/redaction.js +198 -0
- package/dist/ctl/service.d.ts +38 -0
- package/dist/ctl/service.js +156 -0
- package/dist/ctl/store.d.ts +51 -0
- package/dist/ctl/store.js +93 -0
- package/dist/ctl/transfers.d.ts +14 -0
- package/dist/ctl/transfers.js +74 -0
- package/dist/ctl/transport.d.ts +11 -0
- package/dist/ctl/transport.js +79 -0
- package/dist/deploy/deploy.d.ts +4 -3
- package/dist/deploy/deploy.js +5 -210
- package/dist/deploy/plan.js +26 -7
- package/dist/dev.js +5 -4
- package/dist/error-registry.js +1020 -834
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +1 -12
- package/dist/skill.js +51 -9
- package/dist/standalone/api-description-inspect.d.ts +4 -0
- package/dist/standalone/api-description-inspect.js +12 -0
- package/dist/standalone/api-description.d.ts +7 -0
- package/dist/standalone/api-description.js +73 -0
- package/dist/standalone/build.d.ts +3 -0
- package/dist/standalone/build.js +140 -33
- package/dist/standalone/compose-errors.d.ts +3 -0
- package/dist/standalone/compose-errors.js +243 -0
- package/dist/standalone/compose.d.ts +97 -10
- package/dist/standalone/compose.js +306 -75
- package/dist/standalone/daemon.js +111 -29
- package/dist/standalone/dsm-window.js +39 -8
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +12 -0
- package/dist/standalone/inspect-spk.js +666 -136
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +8 -1
- package/dist/standalone/manifest.js +303 -86
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +138 -39
- package/dist/template-paths.d.ts +0 -2
- package/dist/template-paths.js +0 -5
- package/dist/test-command.d.ts +1 -1
- package/dist/test-command.js +1 -1
- package/dist/validate.d.ts +3 -3
- package/dist/validate.js +32 -140
- package/package.json +5 -3
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +3 -1
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +8 -17
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +4 -0
- package/skills/reference/identity-gateway.md +8 -144
- package/skills/reference/wrap.md +10 -39
- package/assets/go/daemon/fixture_linux_test.go +0 -15
- package/assets/go/daemon/fixture_other_test.go +0 -7
- package/assets/go/daemon/go.mod +0 -5
- package/assets/go/daemon/main.go +0 -636
- package/assets/go/daemon/main_test.go +0 -315
- package/assets/go/daemon/network.go +0 -107
- package/assets/go/daemon/network_test.go +0 -86
- package/assets/go/daemon/process_linux.go +0 -55
- package/assets/go/daemon/process_linux_test.go +0 -161
- package/assets/go/daemon/process_other.go +0 -14
- package/assets/go/daemon/testdata/child.go +0 -44
- package/assets/go/gateway/auth.go +0 -101
- package/assets/go/gateway/auth_test.go +0 -216
- package/assets/go/gateway/cgi.go +0 -133
- package/assets/go/gateway/cgi_test.go +0 -156
- package/assets/go/gateway/csrf.go +0 -73
- package/assets/go/gateway/csrf_test.go +0 -155
- package/assets/go/gateway/directory.go +0 -108
- package/assets/go/gateway/directory_test.go +0 -202
- package/assets/go/gateway/gateway.go +0 -412
- package/assets/go/gateway/gateway_test.go +0 -676
- package/assets/go/gateway/go.mod +0 -5
- package/assets/go/gateway/groups.go +0 -66
- package/assets/go/gateway/main.go +0 -45
- package/dist/build-spk/gateway.d.ts +0 -31
- package/dist/build-spk/gateway.js +0 -60
- package/dist/compose/privilege.d.ts +0 -14
- package/dist/compose/privilege.js +0 -102
|
@@ -1,22 +1,109 @@
|
|
|
1
|
-
import type { NativeArchFamily } from
|
|
2
|
-
import { type Daemon } from
|
|
3
|
-
import type { StandaloneError, StandaloneManifest } from
|
|
1
|
+
import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
2
|
+
import { type Daemon } from "./daemon.js";
|
|
3
|
+
import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
4
|
+
export interface ComposePlan {
|
|
5
|
+
ingress?: {
|
|
6
|
+
address: string;
|
|
7
|
+
subnet: string;
|
|
8
|
+
port: number;
|
|
9
|
+
service: string;
|
|
10
|
+
candidate: number;
|
|
11
|
+
};
|
|
12
|
+
schema: number;
|
|
13
|
+
project_name: string;
|
|
14
|
+
package: string;
|
|
15
|
+
inputs: Array<{
|
|
16
|
+
path: string;
|
|
17
|
+
sha256: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}>;
|
|
20
|
+
override: string;
|
|
21
|
+
effective: string;
|
|
22
|
+
sha256: string;
|
|
23
|
+
mounts_blocked: boolean;
|
|
24
|
+
acceptance_blockers: string[];
|
|
25
|
+
services: Array<{
|
|
26
|
+
service: string;
|
|
27
|
+
user: string;
|
|
28
|
+
container_root: boolean;
|
|
29
|
+
egress: string;
|
|
30
|
+
network: string;
|
|
31
|
+
mounts: unknown[] | null;
|
|
32
|
+
}>;
|
|
33
|
+
images: Array<{
|
|
34
|
+
service: string;
|
|
35
|
+
source: string;
|
|
36
|
+
reference: string;
|
|
37
|
+
pinned: boolean;
|
|
38
|
+
}>;
|
|
39
|
+
image_proofs?: Record<string, unknown>;
|
|
40
|
+
lock: Record<string, unknown>;
|
|
41
|
+
}
|
|
4
42
|
export interface ComposeApp {
|
|
43
|
+
/** Original manifest declaration; runtime semantics belong to the SDK planner. */
|
|
44
|
+
declaration: Record<string, unknown>;
|
|
5
45
|
file: string;
|
|
6
|
-
|
|
46
|
+
sdk: string;
|
|
47
|
+
targets: Daemon["targets"];
|
|
7
48
|
services: Record<string, {
|
|
8
49
|
address_env: string;
|
|
9
50
|
port: number;
|
|
10
51
|
}>;
|
|
52
|
+
web?: {
|
|
53
|
+
service: string;
|
|
54
|
+
port: number;
|
|
55
|
+
host_port?: number;
|
|
56
|
+
base_path?: string;
|
|
57
|
+
health?: {
|
|
58
|
+
path: string;
|
|
59
|
+
status: number;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
11
62
|
document: Record<string, any>;
|
|
63
|
+
plan: ComposePlan;
|
|
12
64
|
}
|
|
13
|
-
|
|
14
|
-
export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
65
|
+
export declare const COMPOSE_FIELDS: readonly ["resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
|
|
66
|
+
export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
|
|
67
|
+
package?: string;
|
|
68
|
+
sdk?: string;
|
|
69
|
+
mode?: string;
|
|
70
|
+
composeMounts?: boolean;
|
|
71
|
+
resolveImages?: boolean;
|
|
72
|
+
architecture?: NativeArchFamily;
|
|
73
|
+
imageProofs?: unknown;
|
|
74
|
+
}): ComposeApp | undefined;
|
|
75
|
+
export declare function composeDaemon(m: Pick<StandaloneManifest, "compose">): Daemon;
|
|
76
|
+
export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "identityGateway" | "spkVersion">): {
|
|
18
77
|
public_ports: number[];
|
|
78
|
+
readiness?: {
|
|
79
|
+
version: string;
|
|
80
|
+
plan_sha256: string;
|
|
81
|
+
health: {
|
|
82
|
+
path: string;
|
|
83
|
+
status: number;
|
|
84
|
+
} | undefined;
|
|
85
|
+
} | undefined;
|
|
86
|
+
ingress?: {
|
|
87
|
+
candidate: number;
|
|
88
|
+
} | undefined;
|
|
89
|
+
mode: string;
|
|
90
|
+
ports: number[];
|
|
19
91
|
};
|
|
20
92
|
export declare function composeSource(c: ComposeApp): string;
|
|
21
|
-
/**
|
|
93
|
+
/** Retain upstream bytes, the true override and a lock without secret values. */
|
|
94
|
+
export declare function writeComposePayload(c: ComposeApp, root: string, target: string, gateway?: unknown): void;
|
|
95
|
+
/** Only package identity values are materialized, in the incoming tree. */
|
|
22
96
|
export declare function renderComposeInstall(pkg: string): string;
|
|
97
|
+
/** The same SDK entry point supplies the package-account install materializer. */
|
|
98
|
+
export declare function buildComposeControl(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: import("./build.js").CommandRunner): Promise<string | undefined>;
|
|
99
|
+
/** Only the build resolves registry metadata; check and inspect remain offline. */
|
|
100
|
+
export declare function lockComposeImages(m: StandaloneManifest, arch: NativeArchFamily, resolveImages: boolean, imageProofs?: unknown): StandaloneManifest;
|
|
101
|
+
export declare function composeInputs(root: string, file: string, envFile?: string): ComposePlan["inputs"];
|
|
102
|
+
/** Local development uses the same SDK model and package-account preparer. */
|
|
103
|
+
export declare function prepareLocalCompose(m: StandaloneManifest, pkgvar: string): void;
|
|
104
|
+
/** DSM first-install preinst runs before pkgvar exists and before worker acquire. */
|
|
105
|
+
export declare function dsmComposeProblem(c: ComposeApp): {
|
|
106
|
+
code: string;
|
|
107
|
+
message: string;
|
|
108
|
+
} | undefined;
|
|
109
|
+
export declare const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
|
|
@@ -1,90 +1,321 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { COMPOSE_ERRORS } from "./compose-errors.js";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { parse } from "yaml";
|
|
8
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
9
|
+
import { goChildEnv } from "../runtime/go-env.js";
|
|
10
|
+
import { sdkSource } from "../runtime/sdk-source.js";
|
|
11
|
+
import { readDaemon } from "./daemon.js";
|
|
12
|
+
export const COMPOSE_FIELDS = [
|
|
13
|
+
"resource_prefix",
|
|
14
|
+
"files",
|
|
15
|
+
"env_files",
|
|
16
|
+
"profiles",
|
|
17
|
+
"web",
|
|
18
|
+
"data",
|
|
19
|
+
"shares",
|
|
20
|
+
"network",
|
|
21
|
+
"services",
|
|
22
|
+
"helpers",
|
|
23
|
+
"sdk",
|
|
24
|
+
];
|
|
25
|
+
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
26
|
+
const planners = new Map();
|
|
27
|
+
/** Build one trusted SDK tool per process. Never execute the App's payload. */
|
|
28
|
+
function planner(sdk) {
|
|
29
|
+
if (process.env.SYNOPKGLAND_COMPOSE_PLANNER &&
|
|
30
|
+
process.env.SYNOPKGLAND_SDK_DIR)
|
|
31
|
+
return process.env.SYNOPKGLAND_COMPOSE_PLANNER;
|
|
32
|
+
const source = sdkSource(sdk);
|
|
33
|
+
const key = source.goDir;
|
|
34
|
+
const cached = planners.get(key);
|
|
35
|
+
if (cached)
|
|
36
|
+
return cached;
|
|
37
|
+
if (!existsSync(join(source.goDir, "compose/cmd/plan/main.go")))
|
|
38
|
+
throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK has no unified Compose planner; use the matching SDK/CLI release.");
|
|
39
|
+
const work = mkdtempSync(join(tmpdir(), "synopkgland-compose-tool-"));
|
|
40
|
+
const out = join(work, "plan");
|
|
41
|
+
const result = spawnSync("go", ["build", "-trimpath", "-buildvcs=false", "-o", out, "./compose/cmd/plan"], {
|
|
42
|
+
cwd: source.goDir,
|
|
43
|
+
env: goChildEnv(),
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
timeout: 120_000,
|
|
46
|
+
maxBuffer: 2 << 20,
|
|
47
|
+
});
|
|
48
|
+
if (result.status !== 0) {
|
|
49
|
+
rmSync(work, { recursive: true, force: true });
|
|
50
|
+
throw new Error(`[SDK_COMPOSE_BUILD_FAILED] Cannot build the SDK planner: ${result.stderr || result.error?.message}`);
|
|
51
|
+
}
|
|
52
|
+
planners.set(key, out);
|
|
53
|
+
process.once("exit", () => rmSync(work, { recursive: true, force: true }));
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
10
57
|
if (value === undefined)
|
|
11
58
|
return;
|
|
12
|
-
const invalid = (
|
|
13
|
-
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
59
|
+
const invalid = (message, code = "COMPOSE_REQUEST_INVALID") => {
|
|
60
|
+
fail({ code, message });
|
|
61
|
+
return undefined;
|
|
62
|
+
};
|
|
63
|
+
if (arches.some((a) => a !== "x86_64" && a !== "aarch64"))
|
|
64
|
+
return invalid("Container Manager supports x86_64 and aarch64, not armv7.");
|
|
65
|
+
if (!record(value) ||
|
|
66
|
+
Object.keys(value).some((k) => !COMPOSE_FIELDS.includes(k)) ||
|
|
67
|
+
!Array.isArray(value.files))
|
|
68
|
+
return invalid("compose requires files, with optional env_files, profiles, web, data, shares, network, services, helpers and sdk; old file/targets syntax is removed.");
|
|
69
|
+
if (value.sdk !== undefined &&
|
|
70
|
+
(typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
|
|
71
|
+
return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
|
|
72
|
+
const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? CLI_VERSION);
|
|
73
|
+
let targets = {};
|
|
74
|
+
if (value.helpers !== undefined) {
|
|
75
|
+
const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail);
|
|
76
|
+
if (!d)
|
|
77
|
+
return;
|
|
78
|
+
targets = d.targets;
|
|
25
79
|
}
|
|
26
|
-
|
|
27
|
-
|
|
80
|
+
const { helpers: _, sdk: __, ...declaration } = value;
|
|
81
|
+
try {
|
|
82
|
+
const result = spawnSync(planner(sdk), [], {
|
|
83
|
+
input: JSON.stringify({
|
|
84
|
+
...declaration,
|
|
85
|
+
root,
|
|
86
|
+
package: context.package ?? "ComposeApp",
|
|
87
|
+
identity_mode: context.mode ?? "none",
|
|
88
|
+
compose_mounts: context.composeMounts === true,
|
|
89
|
+
resolve_images: context.resolveImages === true,
|
|
90
|
+
registry_mirrors: context.resolveImages &&
|
|
91
|
+
process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
|
|
92
|
+
? JSON.parse(process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS)
|
|
93
|
+
: undefined,
|
|
94
|
+
architecture: context.architecture,
|
|
95
|
+
image_proofs: context.imageProofs,
|
|
96
|
+
}),
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
timeout: 65_000,
|
|
99
|
+
maxBuffer: 48 << 20,
|
|
100
|
+
});
|
|
101
|
+
let plan;
|
|
102
|
+
try {
|
|
103
|
+
plan = JSON.parse(result.stdout);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
throw new Error(`[SDK_COMPOSE_FAILED] Planner did not return JSON: ${result.stderr || result.error?.message}`);
|
|
107
|
+
}
|
|
108
|
+
if (result.status !== 0 || plan.error)
|
|
109
|
+
throw new Error(plan.error ?? "[SDK_COMPOSE_FAILED] SDK planner failed.");
|
|
110
|
+
const document = parse(plan.effective);
|
|
111
|
+
const web = value.web;
|
|
112
|
+
return {
|
|
113
|
+
declaration: { ...value },
|
|
114
|
+
sdk,
|
|
115
|
+
file: join(root, String(value.files[0])),
|
|
116
|
+
targets,
|
|
117
|
+
web,
|
|
118
|
+
services: web
|
|
119
|
+
? {
|
|
120
|
+
[web.service]: {
|
|
121
|
+
address_env: record(value.services?.[web.service])
|
|
122
|
+
? (value.services[web.service].address_env ?? "")
|
|
123
|
+
: "",
|
|
124
|
+
port: plan.ingress?.port ?? web.host_port ?? web.port,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
: {},
|
|
128
|
+
document,
|
|
129
|
+
plan,
|
|
130
|
+
};
|
|
28
131
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return invalid('only services are supported; every container needs a services binding policy.');
|
|
34
|
-
if (Object.keys(value.services).sort().join() !== Object.keys(doc.services).sort().join())
|
|
35
|
-
return invalid('declare a binding policy for every deployed container.');
|
|
36
|
-
const ports = new Set();
|
|
37
|
-
for (const [name, service] of Object.entries(doc.services)) {
|
|
38
|
-
const policy = value.services[name];
|
|
39
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !record(policy) || !only(policy, ['address_env', 'port']) || !/^[A-Z][A-Z0-9_]*$/.test(policy.address_env) || !Number.isInteger(policy.port) || policy.port < 1024 || policy.port > 65535 || ports.has(policy.port))
|
|
40
|
-
return invalid(`services.${name} needs address_env and a unique unprivileged port.`);
|
|
41
|
-
ports.add(policy.port);
|
|
42
|
-
if (!record(service) || !only(service, ['image', 'network_mode', 'user', 'cap_drop', 'security_opt', 'environment', 'volumes', 'restart', 'command', 'entrypoint', 'working_dir']))
|
|
43
|
-
return invalid(`${name}: unsupported Compose fields (no build, ports, devices, capabilities, namespaces or Docker socket).`);
|
|
44
|
-
if (service.network_mode !== 'host')
|
|
45
|
-
return invalid(`${name}: identity_gateway requires network_mode: host with the app bound to 127.0.0.1; bridge cannot reach the loopback issuer.`);
|
|
46
|
-
if (typeof service.image !== 'string' || !service.image.trim() || /\s/.test(service.image))
|
|
47
|
-
return invalid(`${name}: use a prebuilt image.`);
|
|
48
|
-
if (service.user !== '${SYNOPKGLAND_UID}:${SYNOPKGLAND_GID}')
|
|
49
|
-
return invalid(`${name}: user must be \${SYNOPKGLAND_UID}:\${SYNOPKGLAND_GID}, materialized from the package account.`);
|
|
50
|
-
if (JSON.stringify(service.cap_drop) !== '["ALL"]' || JSON.stringify(service.security_opt) !== '["no-new-privileges:true"]')
|
|
51
|
-
return invalid(`${name}: require cap_drop: [ALL] and security_opt: [no-new-privileges:true].`);
|
|
52
|
-
if (!record(service.environment) || service.environment[policy.address_env] !== '127.0.0.1' || Object.values(service.environment).some(v => typeof v !== 'string'))
|
|
53
|
-
return invalid(`${name}: environment.${policy.address_env} must be 127.0.0.1; verify the upstream's actual listen setting.`);
|
|
54
|
-
if (service.restart !== undefined && service.restart !== 'no')
|
|
55
|
-
return invalid(`${name}: restart must be no; Container Manager owns the lifecycle.`);
|
|
56
|
-
if (service.volumes !== undefined && (!Array.isArray(service.volumes) || service.volumes.some((v) => typeof v !== 'string' || !/^\$\{SYNOPKG_PKGVAR\}:\/[A-Za-z0-9_./-]+(?::rw|:ro)?$/.test(v) || v.split(':')[1].split('/').some(p => p === '..' || p === '.') || /docker\.sock|container-manager\.sock/.test(v))))
|
|
57
|
-
return invalid(`${name}: mount only the DSM-created \${SYNOPKG_PKGVAR} root, with a fixed container path.`);
|
|
58
|
-
for (const key of ['command', 'entrypoint'])
|
|
59
|
-
if (service[key] !== undefined && (!Array.isArray(service[key]) || !service[key].every((v) => typeof v === 'string')))
|
|
60
|
-
return invalid(`${name}: ${key} must be argv.`);
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error.message;
|
|
134
|
+
const m = /^\[([A-Z_]+)\]\s*([\s\S]*)$/.exec(message);
|
|
135
|
+
return invalid(m?.[2] ?? message, m?.[1] && COMPOSE_ERRORS[m[1]] ? m[1] : "SDK_COMPOSE_FAILED");
|
|
61
136
|
}
|
|
62
|
-
for (const match of JSON.stringify(doc).matchAll(/\$(?:\{([^}]+)\}|([A-Za-z_][A-Za-z0-9_]*))/g))
|
|
63
|
-
if (!vars.has(match[1] ?? match[2]))
|
|
64
|
-
return invalid('only package UID, GID and var interpolation is supported.');
|
|
65
|
-
return { file: path, targets: payload.targets, services: value.services, document: doc };
|
|
66
137
|
}
|
|
67
138
|
export function composeDaemon(m) {
|
|
68
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
exec: "_sso/gateway",
|
|
141
|
+
args: ["run"],
|
|
142
|
+
targets: m.compose.targets,
|
|
143
|
+
env: {},
|
|
144
|
+
timeouts: { start: 120, stop: 30, status: 5, uninstall: 60 },
|
|
145
|
+
};
|
|
69
146
|
}
|
|
70
147
|
export function composeNetwork(m) {
|
|
71
|
-
|
|
148
|
+
const c = m.compose;
|
|
149
|
+
const host = c.web &&
|
|
150
|
+
c.plan.services.find((s) => s.service === c.web.service)?.network ===
|
|
151
|
+
"host";
|
|
152
|
+
return {
|
|
153
|
+
mode: c.plan.mounts_blocked ? "settings" : host ? "host" : "bridge",
|
|
154
|
+
ports: c.web ? [c.plan.ingress?.port ?? c.web.host_port ?? c.web.port] : [],
|
|
155
|
+
...(c.plan.ingress
|
|
156
|
+
? { ingress: { candidate: c.plan.ingress.candidate } }
|
|
157
|
+
: {}),
|
|
158
|
+
...(!host && c.web && !c.plan.mounts_blocked
|
|
159
|
+
? {
|
|
160
|
+
readiness: {
|
|
161
|
+
version: m.spkVersion,
|
|
162
|
+
plan_sha256: c.plan.sha256,
|
|
163
|
+
health: c.web.health,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
public_ports: Object.values(m.identityGateway?.listen ?? {}).sort((a, b) => a - b),
|
|
168
|
+
};
|
|
72
169
|
}
|
|
73
|
-
export function composeSource(c) {
|
|
74
|
-
|
|
170
|
+
export function composeSource(c) {
|
|
171
|
+
return c.plan.effective;
|
|
172
|
+
}
|
|
173
|
+
/** Retain upstream bytes, the true override and a lock without secret values. */
|
|
174
|
+
export function writeComposePayload(c, root, target, gateway) {
|
|
175
|
+
for (const input of c.plan.inputs) {
|
|
176
|
+
const bytes = readFileSync(join(root, input.path));
|
|
177
|
+
if (createHash("sha256").update(bytes).digest("hex") !== input.sha256)
|
|
178
|
+
throw new Error("[COMPOSE_INPUT_CHANGED] Upstream input changed after planning; check and rebuild.");
|
|
179
|
+
const path = join(target, "upstream", input.path);
|
|
180
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
181
|
+
writeFileSync(path, bytes, { mode: 0o600 });
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(target, { recursive: true });
|
|
184
|
+
for (const [name, content] of Object.entries({
|
|
185
|
+
"docker-compose.yml": c.plan.effective,
|
|
186
|
+
"generated.override.yaml": c.plan.override,
|
|
187
|
+
"compose.lock.json": JSON.stringify(c.plan.lock, null, 2) + "\n",
|
|
188
|
+
"request.json": JSON.stringify({ declaration: c.declaration, sdk: c.sdk, gateway }, null, 2) + "\n",
|
|
189
|
+
"images.json": JSON.stringify(c.plan.image_proofs ?? {}, null, 2) + "\n",
|
|
190
|
+
}))
|
|
191
|
+
writeFileSync(join(target, name), content, { mode: 0o600 });
|
|
192
|
+
}
|
|
193
|
+
/** Only package identity values are materialized, in the incoming tree. */
|
|
75
194
|
export function renderComposeInstall(pkg) {
|
|
76
195
|
return `#!/bin/sh
|
|
77
196
|
set -eu
|
|
78
|
-
|
|
79
|
-
umask 077
|
|
80
|
-
project="\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose"
|
|
81
|
-
[ -d "$project" ] && [ ! -L "$project" ] || exit 1
|
|
82
|
-
[ ! -L "$project/.env" ] || exit 1
|
|
83
|
-
{
|
|
84
|
-
printf 'SYNOPKGLAND_UID=%s\\n' "$(id -u)"
|
|
85
|
-
printf 'SYNOPKGLAND_GID=%s\\n' "$(id -g)"
|
|
86
|
-
printf 'SYNOPKG_PKGVAR=%s\\n' '/var/packages/${pkg}/var'
|
|
87
|
-
} > "$project/.env"
|
|
88
|
-
chmod 600 "$project/.env"
|
|
197
|
+
exec "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose/control" prepare --project "\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose" --package '${pkg}'
|
|
89
198
|
`;
|
|
90
199
|
}
|
|
200
|
+
/** The same SDK entry point supplies the package-account install materializer. */
|
|
201
|
+
export async function buildComposeControl(m, arch, work, run) {
|
|
202
|
+
if (!m.compose)
|
|
203
|
+
return;
|
|
204
|
+
const { pinnedGoBuildEnv, pinnedGoProblem } = await import("../build-spk/go-toolchain.js");
|
|
205
|
+
const { checkCgiBinary } = await import("./binary.js");
|
|
206
|
+
const { StandaloneBuildError } = await import("./spk.js");
|
|
207
|
+
const out = join(work, `compose-control-${arch}`);
|
|
208
|
+
const source = sdkSource(m.compose.sdk);
|
|
209
|
+
const result = await run("go", [
|
|
210
|
+
"build",
|
|
211
|
+
"-trimpath",
|
|
212
|
+
"-buildvcs=false",
|
|
213
|
+
"-ldflags",
|
|
214
|
+
"-s -w -buildid=",
|
|
215
|
+
"-o",
|
|
216
|
+
out,
|
|
217
|
+
"./compose/cmd/plan",
|
|
218
|
+
], {
|
|
219
|
+
cwd: source.goDir,
|
|
220
|
+
env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }),
|
|
221
|
+
});
|
|
222
|
+
if (result.status !== 0 || result.missing)
|
|
223
|
+
throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", `Cannot build the SDK Compose control: ${result.stderr}`);
|
|
224
|
+
const error = checkCgiBinary(work, out, arch);
|
|
225
|
+
if (error)
|
|
226
|
+
throw new StandaloneBuildError(error.code, error.message);
|
|
227
|
+
const toolchain = pinnedGoProblem(out);
|
|
228
|
+
if (toolchain)
|
|
229
|
+
throw new StandaloneBuildError("SDK_COMPOSE_BUILD_FAILED", toolchain);
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/** Only the build resolves registry metadata; check and inspect remain offline. */
|
|
233
|
+
export function lockComposeImages(m, arch, resolveImages, imageProofs) {
|
|
234
|
+
if (!m.compose)
|
|
235
|
+
return m;
|
|
236
|
+
let error;
|
|
237
|
+
const compose = readComposeApp(m.compose.declaration, m.root, m.arches, (e) => {
|
|
238
|
+
error = e;
|
|
239
|
+
}, {
|
|
240
|
+
package: m.package,
|
|
241
|
+
sdk: m.compose.sdk,
|
|
242
|
+
mode: m.identityGateway?.mode ?? "none",
|
|
243
|
+
composeMounts: m.capabilities.composeMounts,
|
|
244
|
+
resolveImages,
|
|
245
|
+
architecture: arch,
|
|
246
|
+
imageProofs,
|
|
247
|
+
});
|
|
248
|
+
if (!compose)
|
|
249
|
+
throw new Error(`[${error?.code ?? "SDK_COMPOSE_FAILED"}] ${error?.message ?? "Cannot lock images"}`);
|
|
250
|
+
return { ...m, compose };
|
|
251
|
+
}
|
|
252
|
+
export function composeInputs(root, file, envFile) {
|
|
253
|
+
const result = spawnSync(planner(CLI_VERSION), [
|
|
254
|
+
"sources",
|
|
255
|
+
"--root",
|
|
256
|
+
root,
|
|
257
|
+
"--file",
|
|
258
|
+
file,
|
|
259
|
+
...(envFile ? ["--env", envFile] : []),
|
|
260
|
+
], { encoding: "utf8", timeout: 65_000, maxBuffer: 48 << 20 });
|
|
261
|
+
const output = JSON.parse(result.stdout);
|
|
262
|
+
if (result.status !== 0 || !Array.isArray(output))
|
|
263
|
+
throw new Error(output.error ?? "[SDK_COMPOSE_FAILED] Cannot inventory upstream inputs.");
|
|
264
|
+
return output;
|
|
265
|
+
}
|
|
266
|
+
/** Local development uses the same SDK model and package-account preparer. */
|
|
267
|
+
export function prepareLocalCompose(m, pkgvar) {
|
|
268
|
+
if (!m.compose)
|
|
269
|
+
return;
|
|
270
|
+
if (m.compose.plan.mounts_blocked)
|
|
271
|
+
throw new Error("[COMPOSE_PROJECT_CONTROL_UNAVAILABLE] Unapproved Shared Folders cannot be mounted by local dev.");
|
|
272
|
+
const target = join(m.root, ".synopkgland/local-compose");
|
|
273
|
+
writeComposePayload(m.compose, m.root, target);
|
|
274
|
+
const result = spawnSync(planner(m.compose.sdk), [
|
|
275
|
+
"prepare",
|
|
276
|
+
"--project",
|
|
277
|
+
target,
|
|
278
|
+
"--package",
|
|
279
|
+
m.package,
|
|
280
|
+
"--pkgvar",
|
|
281
|
+
pkgvar,
|
|
282
|
+
], { encoding: "utf8", timeout: 15_000 });
|
|
283
|
+
if (result.status !== 0)
|
|
284
|
+
throw new Error(result.stderr ||
|
|
285
|
+
"[SDK_COMPOSE_FAILED] SDK local storage preparation failed.");
|
|
286
|
+
if (m.compose.plan.ingress) {
|
|
287
|
+
const remembered = spawnSync(planner(m.compose.sdk), [
|
|
288
|
+
"remember-ingress",
|
|
289
|
+
"--project",
|
|
290
|
+
target,
|
|
291
|
+
"--package",
|
|
292
|
+
m.package,
|
|
293
|
+
"--pkgvar",
|
|
294
|
+
pkgvar,
|
|
295
|
+
], { encoding: "utf8", timeout: 15_000 });
|
|
296
|
+
if (remembered.status !== 0)
|
|
297
|
+
throw new Error(remembered.stderr ||
|
|
298
|
+
"[COMPOSE_SUBNET_STATE_INVALID] Cannot persist the local ingress allocation.");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** DSM first-install preinst runs before pkgvar exists and before worker acquire. */
|
|
302
|
+
export function dsmComposeProblem(c) {
|
|
303
|
+
if (c.plan.mounts_blocked)
|
|
304
|
+
return;
|
|
305
|
+
if (c.plan.services.some((s) => (s.mounts ?? []).some((v) => record(v) &&
|
|
306
|
+
typeof v.source === "string" &&
|
|
307
|
+
v.source.startsWith("${SYNOPKG_PKGVAR}/"))))
|
|
308
|
+
return {
|
|
309
|
+
code: "COMPOSE_PRIVATE_BIND_TIMING_UNSUPPORTED",
|
|
310
|
+
message: "DSM creates pkgvar after preinst, but docker-project acquires before postinst. Package-private binds cannot be initialized safely on a clean install; select a compatible named volume explicitly or keep this configuration unsupported. No root or automatic storage conversion is used.",
|
|
311
|
+
};
|
|
312
|
+
if (c.web &&
|
|
313
|
+
c.plan.services.find((s) => s.service === c.web.service)?.network ===
|
|
314
|
+
"bridge" &&
|
|
315
|
+
!c.web.health)
|
|
316
|
+
return {
|
|
317
|
+
code: "COMPOSE_HEALTH_REQUIRED",
|
|
318
|
+
message: "Bridge web ingress requires compose.web.health: {path: /health, status: 200}; choose the App's actual unauthenticated health endpoint and expected 2xx status.",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
export const COMPOSE_READINESS_LIMITATION = "Availability only: App health + fixed endpoint TCP + package lifecycle acquire generation matching the installed version. This does not attest the actual Docker image, project or generation. Another process already executing locally on the NAS may impersonate the fixed endpoint. Accepted for this local-code-execution threat model; replace with full official readback when available.";
|