@foresthubai/workflow-core 0.4.6 → 0.4.7
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/api/camera.d.ts +254 -0
- package/dist/api/camera.d.ts.map +1 -0
- package/dist/api/camera.js +6 -0
- package/dist/api/camera.js.map +1 -0
- package/dist/api/engine.d.ts +89 -10
- package/dist/api/engine.d.ts.map +1 -1
- package/dist/api/index.d.ts +0 -4
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/workflow.d.ts +61 -4
- package/dist/api/workflow.d.ts.map +1 -1
- package/dist/channel/Channel.d.ts +1 -1
- package/dist/channel/Channel.d.ts.map +1 -1
- package/dist/channel/Channel.js +1 -1
- package/dist/channel/Channel.js.map +1 -1
- package/dist/channel/ChannelDefinition.d.ts.map +1 -1
- package/dist/channel/ChannelDefinition.js +16 -0
- package/dist/channel/ChannelDefinition.js.map +1 -1
- package/dist/channel/serialization.d.ts.map +1 -1
- package/dist/channel/serialization.js +6 -0
- package/dist/channel/serialization.js.map +1 -1
- package/dist/deploy/bindingRequirements.d.ts +4 -0
- package/dist/deploy/bindingRequirements.d.ts.map +1 -0
- package/dist/deploy/bindingRequirements.js +59 -0
- package/dist/deploy/bindingRequirements.js.map +1 -0
- package/dist/deploy/constants.d.ts +8 -0
- package/dist/deploy/constants.d.ts.map +1 -0
- package/dist/deploy/constants.js +26 -0
- package/dist/deploy/constants.js.map +1 -0
- package/dist/deploy/index.d.ts +3 -5
- package/dist/deploy/index.d.ts.map +1 -1
- package/dist/deploy/index.js +10 -2
- package/dist/deploy/index.js.map +1 -1
- package/dist/deploy/inputs.d.ts +30 -3
- package/dist/deploy/inputs.d.ts.map +1 -1
- package/dist/deploy/requirements.d.ts +4 -36
- package/dist/deploy/requirements.d.ts.map +1 -1
- package/dist/deploy/requirements.js +48 -76
- package/dist/deploy/requirements.js.map +1 -1
- package/dist/deploy/spec.d.ts +10 -4
- package/dist/deploy/spec.d.ts.map +1 -1
- package/dist/deploy/spec.js +233 -63
- package/dist/deploy/spec.js.map +1 -1
- package/dist/expression/parser.js +10 -1
- package/dist/expression/parser.js.map +1 -1
- package/dist/model/MLModelDefinition.d.ts +8 -0
- package/dist/model/MLModelDefinition.d.ts.map +1 -0
- package/dist/model/MLModelDefinition.js +14 -0
- package/dist/model/MLModelDefinition.js.map +1 -0
- package/dist/model/Model.d.ts +2 -1
- package/dist/model/Model.d.ts.map +1 -1
- package/dist/model/Model.js +1 -1
- package/dist/model/Model.js.map +1 -1
- package/dist/model/ModelRegistry.d.ts.map +1 -1
- package/dist/model/ModelRegistry.js +2 -0
- package/dist/model/ModelRegistry.js.map +1 -1
- package/dist/model/index.d.ts +1 -0
- package/dist/model/index.d.ts.map +1 -1
- package/dist/model/index.js +1 -0
- package/dist/model/index.js.map +1 -1
- package/dist/model/serialization.d.ts.map +1 -1
- package/dist/model/serialization.js +5 -0
- package/dist/model/serialization.js.map +1 -1
- package/dist/node/InputNode.d.ts +10 -2
- package/dist/node/InputNode.d.ts.map +1 -1
- package/dist/node/InputNode.js +16 -0
- package/dist/node/InputNode.js.map +1 -1
- package/dist/node/MLInferenceNode.d.ts +15 -0
- package/dist/node/MLInferenceNode.d.ts.map +1 -0
- package/dist/node/MLInferenceNode.js +26 -0
- package/dist/node/MLInferenceNode.js.map +1 -0
- package/dist/node/Node.d.ts +3 -2
- package/dist/node/Node.d.ts.map +1 -1
- package/dist/node/NodeRegistry.d.ts.map +1 -1
- package/dist/node/NodeRegistry.js +4 -1
- package/dist/node/NodeRegistry.js.map +1 -1
- package/dist/node/methods.d.ts.map +1 -1
- package/dist/node/methods.js +6 -0
- package/dist/node/methods.js.map +1 -1
- package/dist/node/serialization.d.ts.map +1 -1
- package/dist/node/serialization.js +42 -0
- package/dist/node/serialization.js.map +1 -1
- package/package.json +2 -2
- package/src/api/index.ts +0 -15
- package/src/api/workflow.ts +61 -4
- package/src/channel/Channel.ts +2 -2
- package/src/channel/ChannelDefinition.ts +16 -0
- package/src/channel/serialization.ts +6 -0
- package/src/deploy/constants.ts +30 -0
- package/src/deploy/index.ts +14 -17
- package/src/deploy/requirements.test.ts +164 -64
- package/src/deploy/requirements.ts +104 -159
- package/src/diagnostics/diagnostics.test.ts +40 -22
- package/src/expression/parser.ts +9 -1
- package/src/model/MLModelDefinition.ts +16 -0
- package/src/model/Model.ts +6 -2
- package/src/model/ModelRegistry.ts +2 -0
- package/src/model/index.ts +1 -0
- package/src/model/serialization.ts +5 -0
- package/src/node/InputNode.ts +28 -2
- package/src/node/MLInferenceNode.ts +46 -0
- package/src/node/Node.ts +3 -1
- package/src/node/NodeRegistry.ts +4 -1
- package/src/node/methods.ts +6 -0
- package/src/node/serialization.ts +44 -2
- package/src/workflow/serialization.test.ts +53 -0
- package/src/api/deployment.ts +0 -74
- package/src/api/engine.ts +0 -744
- package/src/deploy/inputs.ts +0 -44
- package/src/deploy/spec.test.ts +0 -227
- package/src/deploy/spec.ts +0 -397
package/src/deploy/inputs.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// Copyright (c) 2026 ForestHub.
|
|
3
|
-
|
|
4
|
-
// Operator-supplied bindings: the per-deploy values that bind a workflow's
|
|
5
|
-
// logical resource ids to a concrete environment. Shared by every spec
|
|
6
|
-
// producer (the CLI prompts/--values, the FE deploy forms) — how the values are
|
|
7
|
-
// collected differs per renderer, but the shape they resolve to is one thing.
|
|
8
|
-
//
|
|
9
|
-
// Secrets policy: provider API keys and the web-search key are NOT here — they
|
|
10
|
-
// are device/operator-scoped engine env, injected at render time, never frozen
|
|
11
|
-
// into a versioned spec. Only deployment-scoped resource creds (broker, custom
|
|
12
|
-
// endpoint) live in these bindings, because EngineConfig.externalResources needs
|
|
13
|
-
// them resolved inline.
|
|
14
|
-
|
|
15
|
-
// One hardware channel's physical address. `index` = sub-address (addressable
|
|
16
|
-
// families: GPIO line, ADC/DAC/PWM channel); `baud` = serial only.
|
|
17
|
-
export interface HardwareBinding {
|
|
18
|
-
chipOrDevice: string;
|
|
19
|
-
index?: number;
|
|
20
|
-
baud?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// One MQTT channel's broker connection.
|
|
24
|
-
export interface MqttBinding {
|
|
25
|
-
brokerUrl: string;
|
|
26
|
-
username?: string;
|
|
27
|
-
password?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// One custom model's runtime location. `device` = a llama-server sidecar on this
|
|
31
|
-
// controller (the engine reaches it over the container network by service name);
|
|
32
|
-
// `network` = an inference endpoint the operator runs elsewhere. The endpoint
|
|
33
|
-
// serves the model under its workflow id — no upstream-name aliasing yet.
|
|
34
|
-
export type ModelBinding =
|
|
35
|
-
| { location: "device"; modelFile: string; port?: number; ctxSize?: number }
|
|
36
|
-
| { location: "network"; url: string; apiKey?: string };
|
|
37
|
-
|
|
38
|
-
// The complete set of bindings a deploy supplies, keyed by workflow logical id
|
|
39
|
-
// (channel id / model id). Empty for any resource kind the workflow doesn't use.
|
|
40
|
-
export interface DeploymentInputs {
|
|
41
|
-
hardware: Record<string, HardwareBinding>;
|
|
42
|
-
mqtt: Record<string, MqttBinding>;
|
|
43
|
-
models: Record<string, ModelBinding>;
|
|
44
|
-
}
|
package/src/deploy/spec.test.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// Copyright (c) 2026 ForestHub.
|
|
3
|
-
|
|
4
|
-
import { describe, it, expect } from "vitest";
|
|
5
|
-
import { buildDeploymentSpec, assertDeployable, sidecarServiceName } from "./spec";
|
|
6
|
-
import { deriveRequirements } from "./requirements";
|
|
7
|
-
import type { DeploymentInputs } from "./inputs";
|
|
8
|
-
import { MAIN_CANVAS_ID, type Workflow } from "../workflow";
|
|
9
|
-
import type { Channel } from "../channel";
|
|
10
|
-
import type { Model } from "../model";
|
|
11
|
-
import type { DeploymentSchemas, EngineSchemas } from "../api";
|
|
12
|
-
|
|
13
|
-
type Spec = DeploymentSchemas["DeploymentSpec"];
|
|
14
|
-
|
|
15
|
-
// The engine is the component named "engine"; its config is transported as an
|
|
16
|
-
// opaque blob, so cast it back to EngineConfig to inspect manifest/mapping/etc.
|
|
17
|
-
function engineOf(spec: Spec): DeploymentSchemas["DeployComponent"] {
|
|
18
|
-
const e = spec.components.find((c) => c.name === "engine");
|
|
19
|
-
if (!e) throw new Error("expected engine component");
|
|
20
|
-
return e;
|
|
21
|
-
}
|
|
22
|
-
function engineConfigOf(spec: Spec): EngineSchemas["EngineConfig"] {
|
|
23
|
-
return engineOf(spec).config as EngineSchemas["EngineConfig"];
|
|
24
|
-
}
|
|
25
|
-
const llamaOf = (spec: Spec, modelId: string) => spec.components.find((c) => c.name === sidecarServiceName(modelId));
|
|
26
|
-
|
|
27
|
-
function channel(id: string, type: Channel["type"], args: Record<string, unknown> = {}): Channel {
|
|
28
|
-
return { id, label: id, type, arguments: args };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const customModel: Model = { id: "local-llm", label: "Local", type: "LLMModel", arguments: {} };
|
|
32
|
-
|
|
33
|
-
// One workflow exercising every resource kind: a cdev output (GPIO), a serial
|
|
34
|
-
// device (cdev), a sysfs ADC (privileged), an MQTT channel, and an on-device
|
|
35
|
-
// custom model. Empty main canvas — the resolver reads channels/models, not nodes.
|
|
36
|
-
function fullWorkflow(): Workflow {
|
|
37
|
-
return {
|
|
38
|
-
canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
|
|
39
|
-
functions: {},
|
|
40
|
-
channels: {
|
|
41
|
-
led: channel("led", "GPIOOUT"),
|
|
42
|
-
serial0: channel("serial0", "UART"),
|
|
43
|
-
sensor: channel("sensor", "ADC"),
|
|
44
|
-
telemetry: channel("telemetry", "MQTT", { topic: "t/x" }),
|
|
45
|
-
},
|
|
46
|
-
memory: {},
|
|
47
|
-
models: { "local-llm": customModel },
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const fullInputs: DeploymentInputs = {
|
|
52
|
-
hardware: {
|
|
53
|
-
led: { chipOrDevice: "/dev/gpiochip0", index: 17 },
|
|
54
|
-
serial0: { chipOrDevice: "/dev/ttyUSB0", baud: 9600 },
|
|
55
|
-
sensor: { chipOrDevice: "/sys/bus/iio/devices/iio:device0", index: 0 },
|
|
56
|
-
},
|
|
57
|
-
mqtt: { telemetry: { brokerUrl: "mqtt://broker.local:1883", username: "u", password: "p" } },
|
|
58
|
-
models: { "local-llm": { location: "device", modelFile: "model.gguf" } },
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const meta = {
|
|
62
|
-
id: "dep-1",
|
|
63
|
-
engineImage: "fh-engine:0.4.2",
|
|
64
|
-
llamaServerImage: "ghcr.io/ggml-org/llama.cpp:server-b8589",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
describe("buildDeploymentSpec", () => {
|
|
68
|
-
it("resolves a full workflow into a contract-shaped spec", () => {
|
|
69
|
-
const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
|
|
70
|
-
|
|
71
|
-
expect(spec.schemaVersion).toBe(1);
|
|
72
|
-
expect(spec.id).toBe("dep-1");
|
|
73
|
-
|
|
74
|
-
const engine = engineOf(spec);
|
|
75
|
-
expect(engine.image).toBe("fh-engine:0.4.2");
|
|
76
|
-
expect(engine.pull).toBe("never"); // built locally, in no registry
|
|
77
|
-
expect(engineConfigOf(spec).workflow.schemaVersion).toBeGreaterThanOrEqual(1);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("splits the device manifest by family and maps every resource", () => {
|
|
81
|
-
const config = engineConfigOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
|
|
82
|
-
const m = config.manifest!;
|
|
83
|
-
expect(Object.keys(m.gpios ?? {})).toHaveLength(1);
|
|
84
|
-
expect(Object.keys(m.serials ?? {})).toHaveLength(1);
|
|
85
|
-
expect(Object.keys(m.adcs ?? {})).toHaveLength(1);
|
|
86
|
-
|
|
87
|
-
// Mapping keyed by every workflow logical id; each ref resolves to a built resource.
|
|
88
|
-
expect(Object.keys(config.mapping ?? {}).sort()).toEqual(["led", "local-llm", "sensor", "serial0", "telemetry"]);
|
|
89
|
-
expect(config.mapping!.sensor).toEqual({ ref: expect.any(String), index: 0 });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("resolves cdev nodes into devices and forces privileged for sysfs families", () => {
|
|
93
|
-
const engine = engineOf(buildDeploymentSpec(fullWorkflow(), fullInputs, meta).spec);
|
|
94
|
-
// GPIO + serial are cdev → granted; ADC (sysfs) is not a node → privileged instead.
|
|
95
|
-
expect(engine.devices?.sort()).toEqual(["/dev/gpiochip0", "/dev/ttyUSB0"]);
|
|
96
|
-
expect(engine.privileged).toBe(true);
|
|
97
|
-
// A nonroot image reaching root-owned nodes/sysfs runs as root.
|
|
98
|
-
expect(engine.user).toBe("0:0");
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("emits a llama-server component and points the engine's provider at the sidecar", () => {
|
|
102
|
-
const { spec } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
|
|
103
|
-
expect(llamaOf(spec, "local-llm")).toMatchObject({
|
|
104
|
-
name: sidecarServiceName("local-llm"),
|
|
105
|
-
image: "ghcr.io/ggml-org/llama.cpp:server-b8589",
|
|
106
|
-
command: ["--model", "/var/lib/foresthub/workspace/model.gguf", "--host", "0.0.0.0", "--port", "8080", "--ctx-size", "4096"],
|
|
107
|
-
volumes: ["./workspaces/llama-local-llm:/var/lib/foresthub/workspace:ro"],
|
|
108
|
-
});
|
|
109
|
-
// The external-resource provider URL must point at the sidecar service name.
|
|
110
|
-
const ext = engineConfigOf(spec).externalResources!;
|
|
111
|
-
const provider = Object.values(ext).find((r) => r.type === "selfhosted");
|
|
112
|
-
expect(provider).toMatchObject({ url: `http://${sidecarServiceName("local-llm")}:8080` });
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("omits privileged and the llama sidecar when neither applies", () => {
|
|
116
|
-
const wf: Workflow = {
|
|
117
|
-
canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
|
|
118
|
-
functions: {},
|
|
119
|
-
channels: { led: channel("led", "GPIOOUT") },
|
|
120
|
-
memory: {},
|
|
121
|
-
models: {},
|
|
122
|
-
};
|
|
123
|
-
const { spec } = buildDeploymentSpec(wf, { hardware: { led: { chipOrDevice: "/dev/gpiochip0", index: 1 } }, mqtt: {}, models: {} }, meta);
|
|
124
|
-
const engine = engineOf(spec);
|
|
125
|
-
expect(engine.privileged).toBeUndefined();
|
|
126
|
-
expect(spec.components).toHaveLength(1); // engine only, no sidecar
|
|
127
|
-
expect(engine.devices).toEqual(["/dev/gpiochip0"]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("uses a network model's own endpoint and runs no sidecar", () => {
|
|
131
|
-
const wf: Workflow = {
|
|
132
|
-
canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
|
|
133
|
-
functions: {},
|
|
134
|
-
channels: {},
|
|
135
|
-
memory: {},
|
|
136
|
-
models: { "local-llm": customModel },
|
|
137
|
-
};
|
|
138
|
-
const inputs: DeploymentInputs = {
|
|
139
|
-
hardware: {},
|
|
140
|
-
mqtt: {},
|
|
141
|
-
models: { "local-llm": { location: "network", url: "https://infer.example/v1", apiKey: "k" } },
|
|
142
|
-
};
|
|
143
|
-
const { spec, resourceSecrets } = buildDeploymentSpec(wf, inputs, meta);
|
|
144
|
-
expect(spec.components).toHaveLength(1); // engine only, no sidecar
|
|
145
|
-
// The provider config in the spec is secret-free; the apiKey is pulled out.
|
|
146
|
-
const ext = engineConfigOf(spec).externalResources!;
|
|
147
|
-
const [ref, provider] = Object.entries(ext).find(([, r]) => r.type === "selfhosted")!;
|
|
148
|
-
expect(provider).toEqual({ type: "selfhosted", url: "https://infer.example/v1" });
|
|
149
|
-
expect(resourceSecrets[ref]).toEqual("k");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("pulls MQTT/endpoint secrets out of the spec, keyed by resource ref", () => {
|
|
153
|
-
const { spec, resourceSecrets } = buildDeploymentSpec(fullWorkflow(), fullInputs, meta);
|
|
154
|
-
const ext = engineConfigOf(spec).externalResources!;
|
|
155
|
-
// The stored connection carries metadata but never the password.
|
|
156
|
-
const [mqttRef, mqttConn] = Object.entries(ext).find(([, r]) => r.type === "mqtt")!;
|
|
157
|
-
expect(mqttConn).not.toHaveProperty("password");
|
|
158
|
-
expect(mqttConn).toMatchObject({ username: "u" });
|
|
159
|
-
expect(resourceSecrets[mqttRef]).toEqual("p");
|
|
160
|
-
// Whole spec serialized carries no secret value.
|
|
161
|
-
expect(JSON.stringify(spec)).not.toContain('"p"');
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
describe("buildDeploymentSpec custom components", () => {
|
|
166
|
-
const bareWf: Workflow = {
|
|
167
|
-
canvases: { [MAIN_CANVAS_ID]: { nodes: [], edges: [], variables: {} } },
|
|
168
|
-
functions: {},
|
|
169
|
-
channels: {},
|
|
170
|
-
memory: {},
|
|
171
|
-
models: {},
|
|
172
|
-
};
|
|
173
|
-
const bareInputs: DeploymentInputs = { hardware: {}, mqtt: {}, models: {} };
|
|
174
|
-
|
|
175
|
-
it("merges custom components after engine (and llama)", () => {
|
|
176
|
-
const grafana = { name: "grafana", image: "grafana/grafana:11.3.0", ports: ["3000:3000"] };
|
|
177
|
-
const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [grafana]);
|
|
178
|
-
expect(spec.components.map((c) => c.name)).toEqual(["engine", "grafana"]);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("rejects a custom name colliding with the engine", () => {
|
|
182
|
-
expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [{ name: "engine", image: "x" }])).toThrow(
|
|
183
|
-
/duplicate component name "engine"/,
|
|
184
|
-
);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("rejects a custom name colliding with a generated llama sidecar", () => {
|
|
188
|
-
const dup = { name: sidecarServiceName("local-llm"), image: "x" };
|
|
189
|
-
expect(() => buildDeploymentSpec(fullWorkflow(), fullInputs, meta, [dup])).toThrow(/duplicate component name/);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("allows the same image under different names", () => {
|
|
193
|
-
const a = { name: "graf-a", image: "grafana/grafana:11.3.0" };
|
|
194
|
-
const b = { name: "graf-b", image: "grafana/grafana:11.3.0" };
|
|
195
|
-
const { spec } = buildDeploymentSpec(bareWf, bareInputs, meta, [a, b]);
|
|
196
|
-
expect(spec.components.map((c) => c.name)).toEqual(["engine", "graf-a", "graf-b"]);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("rejects two customs sharing a name", () => {
|
|
200
|
-
const a = { name: "dash", image: "x" };
|
|
201
|
-
const b = { name: "dash", image: "y" };
|
|
202
|
-
expect(() => buildDeploymentSpec(bareWf, bareInputs, meta, [a, b])).toThrow(/duplicate component name "dash"/);
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
describe("assertDeployable", () => {
|
|
207
|
-
it("throws listing every unbound resource", () => {
|
|
208
|
-
const req = deriveRequirements(fullWorkflow());
|
|
209
|
-
expect(() => assertDeployable(req, { hardware: {}, mqtt: {}, models: {} })).toThrow(/device path[\s\S]*broker URL[\s\S]*model/);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("rejects a non-GGUF on-device model file", () => {
|
|
213
|
-
const req = deriveRequirements(fullWorkflow());
|
|
214
|
-
const inputs = { ...fullInputs, models: { "local-llm": { location: "device" as const, modelFile: "model.bin" } } };
|
|
215
|
-
expect(() => assertDeployable(req, inputs)).toThrow(/\.gguf/);
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe("deriveRequirements", () => {
|
|
220
|
-
it("classifies channels into hardware families and mqtt", () => {
|
|
221
|
-
const req = deriveRequirements(fullWorkflow());
|
|
222
|
-
expect(req.hardwareChannels.map((c) => c.family).sort()).toEqual(["adc", "gpio", "serial"]);
|
|
223
|
-
expect(req.mqttChannels.map((c) => c.id)).toEqual(["telemetry"]);
|
|
224
|
-
expect(req.customModels.map((c) => c.id)).toEqual(["local-llm"]);
|
|
225
|
-
expect(req.hardwareChannels.find((c) => c.family === "serial")?.addressable).toBe(false);
|
|
226
|
-
});
|
|
227
|
-
});
|
package/src/deploy/spec.ts
DELETED
|
@@ -1,397 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
// Copyright (c) 2026 ForestHub.
|
|
3
|
-
|
|
4
|
-
// The spec resolver: (workflow + bindings) -> DeploymentSpec. The shared
|
|
5
|
-
// "packaging library" the migration doc calls for — component-set derivation
|
|
6
|
-
// and device-grant resolution computed once here, frozen into the contract spec,
|
|
7
|
-
// so neither renderer (OSS one-shot CLI, paid ranger) re-derives them.
|
|
8
|
-
//
|
|
9
|
-
// Every produced field is typed against the generated deployment contract, so a
|
|
10
|
-
// contract change stops this compiling — the drift guard for the spec.
|
|
11
|
-
|
|
12
|
-
import type { Workflow } from "../workflow";
|
|
13
|
-
import { serialize } from "../workflow";
|
|
14
|
-
import type { DeploymentSchemas, EngineSchemas } from "../api";
|
|
15
|
-
import { deriveRequirements } from "./requirements";
|
|
16
|
-
import type { DeployRequirements, HardwareChannel, HardwareFamily } from "./requirements";
|
|
17
|
-
import type { DeploymentInputs, HardwareBinding } from "./inputs";
|
|
18
|
-
|
|
19
|
-
type DeploymentSpec = DeploymentSchemas["DeploymentSpec"];
|
|
20
|
-
type DeployComponent = DeploymentSchemas["DeployComponent"];
|
|
21
|
-
// Typed against the engine's own contract (engine.yaml), not the deployment one:
|
|
22
|
-
// the spec carries it as an opaque blob in DeployComponent.config.
|
|
23
|
-
type EngineConfig = EngineSchemas["EngineConfig"];
|
|
24
|
-
|
|
25
|
-
// In-container mount path for a component's durable workspace. MUST equal the Go
|
|
26
|
-
// component.Workspace contract constant (go/component/paths.go) — the component
|
|
27
|
-
// reads/writes there, so we mount the host workspace dir there and wire no env var.
|
|
28
|
-
// A cross-repo contract: changing it requires changing the Go constant in lockstep.
|
|
29
|
-
const COMPONENT_WORKSPACE_PATH = "/var/lib/foresthub/workspace";
|
|
30
|
-
|
|
31
|
-
// The OSS renderer's state root (docs/device-filesystem.md §2). Bind-mount sources
|
|
32
|
-
// hang off it as `<root>/workspaces/<container>/`. "." makes Docker resolve them
|
|
33
|
-
// relative to the compose file, so the bundle stays drop-anywhere; Ranger is a
|
|
34
|
-
// separate renderer that sets root to the absolute `/var/lib/foresthub`.
|
|
35
|
-
const STATE_ROOT = ".";
|
|
36
|
-
|
|
37
|
-
// Host bind-mount source for a container's workspace under the state root.
|
|
38
|
-
function workspaceDir(container: string): string {
|
|
39
|
-
return `${STATE_ROOT}/workspaces/${container}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Deploy-time metadata the resolver cannot derive from the workflow: identity,
|
|
43
|
-
// lifecycle, and the full image reference each component runs (frozen here so the
|
|
44
|
-
// renderer emits a coordinate rather than assembling one). llamaServerImage is
|
|
45
|
-
// used only when the workflow has an on-device model.
|
|
46
|
-
export interface DeploymentSpecMeta {
|
|
47
|
-
id: string;
|
|
48
|
-
createdAt?: string;
|
|
49
|
-
engineImage: string;
|
|
50
|
-
llamaServerImage: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// The engine's secret store: a flat map of secret id -> opaque secret value,
|
|
54
|
-
// keyed by the same resource ref the spec's externalResources use. Each value is
|
|
55
|
-
// the single credential that resource needs (MQTT password, self-hosted-LLM
|
|
56
|
-
// bearer token). Secrets are NEVER part of the spec (not rotation-safe, breach-
|
|
57
|
-
// exposed if stored): the resolver returns them separately, for the renderer to
|
|
58
|
-
// deliver out-of-band as a mounted secret document (secrets.json). Mirrors the
|
|
59
|
-
// wire EngineSecrets.
|
|
60
|
-
export type EngineSecrets = Record<string, string>;
|
|
61
|
-
|
|
62
|
-
// buildDeploymentSpec's output: the secret-free spec plus the pulled-out secrets.
|
|
63
|
-
export interface DeploymentSpecResult {
|
|
64
|
-
spec: DeploymentSpec;
|
|
65
|
-
resourceSecrets: EngineSecrets;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Hands out stable, collision-free ref names. Same dedup key -> same ref (the
|
|
69
|
-
// engine builds that resource once); distinct keys preferring the same name get
|
|
70
|
-
// suffixed (-2, -3, ...) so the flat ref namespace stays unambiguous.
|
|
71
|
-
class RefAllocator {
|
|
72
|
-
private readonly byKey = new Map<string, string>();
|
|
73
|
-
private readonly used = new Set<string>();
|
|
74
|
-
|
|
75
|
-
alloc(key: string, hint: string): string {
|
|
76
|
-
const existing = this.byKey.get(key);
|
|
77
|
-
if (existing) return existing;
|
|
78
|
-
let ref = hint;
|
|
79
|
-
for (let n = 2; this.used.has(ref); n++) ref = `${hint}-${n}`;
|
|
80
|
-
this.used.add(ref);
|
|
81
|
-
this.byKey.set(key, ref);
|
|
82
|
-
return ref;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Last path/URL segment, reduced to a safe ref token.
|
|
87
|
-
// "/dev/gpiochip0" -> "gpiochip0", ".../iio:device0" -> "iio:device0".
|
|
88
|
-
function basename(p: string): string {
|
|
89
|
-
const tail = p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
90
|
-
return tail.replace(/[^A-Za-z0-9._:-]/g, "-") || "res";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Host of a broker URL, for a readable ref. "mqtt" when unparseable.
|
|
94
|
-
function brokerHost(url: string): string {
|
|
95
|
-
try {
|
|
96
|
-
return new URL(url).hostname || "mqtt";
|
|
97
|
-
} catch {
|
|
98
|
-
return "mqtt";
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Compose/container service name for an on-device model's llama-server sidecar.
|
|
103
|
-
// Single source of truth: the resolver derives the provider URL from it and the
|
|
104
|
-
// renderer emits a service with the same name — they must agree or the URL
|
|
105
|
-
// points at a service that doesn't exist.
|
|
106
|
-
export function sidecarServiceName(modelId: string): string {
|
|
107
|
-
const slug = modelId
|
|
108
|
-
.toLowerCase()
|
|
109
|
-
.replace(/[^a-z0-9._-]+/g, "-")
|
|
110
|
-
.replace(/^-+|-+$/g, "");
|
|
111
|
-
return `llama-${slug || "model"}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Why an on-device model filename is unacceptable, or null when fine. A name
|
|
115
|
-
// check only — the file doesn't exist yet at spec time. Shared with the prompts
|
|
116
|
-
// so input collection and the resolver reject the same input.
|
|
117
|
-
export function ggufNameError(name: string | undefined): string | null {
|
|
118
|
-
const t = (name ?? "").trim();
|
|
119
|
-
if (!t) return "a model filename is required";
|
|
120
|
-
if (!t.toLowerCase().endsWith(".gguf")) return "must be a .gguf file (llama-server only loads GGUF)";
|
|
121
|
-
if (t.includes("/")) return "just the filename, not a path — the file goes in the model's workspace dir (./workspaces/llama-…/)";
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// A physical address belongs to exactly one channel — the engine doesn't police
|
|
126
|
-
// this and would silently let the last claimer win. Same key = collision;
|
|
127
|
-
// sharing just a chip path is fine (one chip, many lines), except serial where
|
|
128
|
-
// the path IS the device. Exported so input collection (prompts) can reject a
|
|
129
|
-
// duplicate at entry with the same identity the resolver validates against.
|
|
130
|
-
export function hardwareAddressKey(family: HardwareFamily, chipOrDevice: string, index?: number): string {
|
|
131
|
-
const dev = chipOrDevice.trim();
|
|
132
|
-
return family === "serial" ? `serial:${dev}` : `${family}:${dev}:${index}`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// The same address phrased for an error message: "/dev/gpiochip0 line 17".
|
|
136
|
-
export function hardwareAddressLabel(family: HardwareFamily, chipOrDevice: string, index?: number): string {
|
|
137
|
-
const dev = chipOrDevice.trim();
|
|
138
|
-
if (family === "serial") return dev;
|
|
139
|
-
return `${dev} ${family === "gpio" ? "line" : "channel"} ${index}`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// One message per channel whose address an earlier channel already claimed.
|
|
143
|
-
// Incomplete bindings are skipped (completeness is checked separately).
|
|
144
|
-
export function hardwareConflicts(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
|
|
145
|
-
const conflicts: string[] = [];
|
|
146
|
-
const claimed = new Map<string, string>(); // address key -> channel id holding it
|
|
147
|
-
for (const ch of channels) {
|
|
148
|
-
const b = bindings[ch.id];
|
|
149
|
-
if (!b?.chipOrDevice || (ch.addressable && b.index === undefined)) continue;
|
|
150
|
-
const key = hardwareAddressKey(ch.family, b.chipOrDevice, b.index);
|
|
151
|
-
const holder = claimed.get(key);
|
|
152
|
-
if (holder) {
|
|
153
|
-
conflicts.push(`hardware "${ch.id}": ${hardwareAddressLabel(ch.family, b.chipOrDevice, b.index)} is already used by "${holder}"`);
|
|
154
|
-
} else {
|
|
155
|
-
claimed.set(key, ch.id);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return conflicts;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// One message per binding carrying a field its family doesn't have (`baud` is
|
|
162
|
-
// serial-only, `index` is everything-but-serial). Usually a mixed-up channel id
|
|
163
|
-
// in a machine-written binding set — reject loudly instead of ignoring.
|
|
164
|
-
export function familyMismatches(channels: HardwareChannel[], bindings: Record<string, HardwareBinding>): string[] {
|
|
165
|
-
const mismatches: string[] = [];
|
|
166
|
-
for (const ch of channels) {
|
|
167
|
-
const b = bindings[ch.id];
|
|
168
|
-
if (!b) continue;
|
|
169
|
-
if (ch.family !== "serial" && b.baud !== undefined) {
|
|
170
|
-
mismatches.push(`hardware "${ch.id}": "baud" only applies to serial channels (this is a ${ch.family} channel)`);
|
|
171
|
-
}
|
|
172
|
-
if (ch.family === "serial" && b.index !== undefined) {
|
|
173
|
-
mismatches.push(`hardware "${ch.id}": "index" does not apply to serial channels (the device path is the full address)`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return mismatches;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Exhaustiveness guard over HardwareFamily — a new family breaks compilation
|
|
180
|
-
// here until handled.
|
|
181
|
-
function assertNeverFamily(f: never): never {
|
|
182
|
-
throw new Error(`unhandled hardware family: ${String(f)}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Mirror of the engine's deploy-layer validation: every declared resource must
|
|
186
|
-
// carry a binding or the engine fatals at boot. Collects ALL gaps so a caller
|
|
187
|
-
// sees them at once. Throws on any gap — the last guard before a dead spec.
|
|
188
|
-
export function assertDeployable(req: DeployRequirements, inputs: DeploymentInputs): void {
|
|
189
|
-
const missing: string[] = [];
|
|
190
|
-
for (const ch of req.hardwareChannels) {
|
|
191
|
-
const b = inputs.hardware[ch.id];
|
|
192
|
-
if (!b?.chipOrDevice) missing.push(`hardware "${ch.id}": device path`);
|
|
193
|
-
else if (ch.addressable && b.index === undefined) missing.push(`hardware "${ch.id}": index`);
|
|
194
|
-
}
|
|
195
|
-
for (const ch of req.mqttChannels) {
|
|
196
|
-
if (!inputs.mqtt[ch.id]?.brokerUrl) missing.push(`mqtt "${ch.id}": broker URL`);
|
|
197
|
-
}
|
|
198
|
-
for (const m of req.customModels) {
|
|
199
|
-
const b = inputs.models[m.id];
|
|
200
|
-
if (b?.location === "device") {
|
|
201
|
-
const err = ggufNameError(b.modelFile);
|
|
202
|
-
if (err) missing.push(`model "${m.id}": ${err}`);
|
|
203
|
-
} else if (!b?.url) {
|
|
204
|
-
missing.push(`model "${m.id}": endpoint URL`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
missing.push(...hardwareConflicts(req.hardwareChannels, inputs.hardware));
|
|
208
|
-
missing.push(...familyMismatches(req.hardwareChannels, inputs.hardware));
|
|
209
|
-
if (missing.length > 0) {
|
|
210
|
-
throw new Error(`invalid deploy config:\n - ${missing.join("\n - ")}`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// One compose service per name: a duplicate name would collapse two components
|
|
215
|
-
// onto one service and silently drop the loser. Names only — same image under
|
|
216
|
-
// different names is legitimate (e.g. two grafanas).
|
|
217
|
-
function assertNoNameCollisions(components: DeployComponent[]): void {
|
|
218
|
-
const seen = new Set<string>();
|
|
219
|
-
for (const c of components) {
|
|
220
|
-
if (seen.has(c.name)) throw new Error(`duplicate component name "${c.name}"`);
|
|
221
|
-
seen.add(c.name);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// buildDeploymentSpec resolves a workflow plus its bindings into a complete,
|
|
226
|
-
// contract-defined DeploymentSpec. Throws (via assertDeployable) if any declared
|
|
227
|
-
// resource is unbound. The embedded engine config carries the serialized (and
|
|
228
|
-
// thereby pruned) workflow; device grants and privileged are resolved here.
|
|
229
|
-
// customComponents are operator-authored containers, merged in verbatim.
|
|
230
|
-
export function buildDeploymentSpec(
|
|
231
|
-
workflow: Workflow,
|
|
232
|
-
inputs: DeploymentInputs,
|
|
233
|
-
meta: DeploymentSpecMeta,
|
|
234
|
-
customComponents: DeployComponent[] = [],
|
|
235
|
-
): DeploymentSpecResult {
|
|
236
|
-
const req = deriveRequirements(workflow);
|
|
237
|
-
assertDeployable(req, inputs);
|
|
238
|
-
|
|
239
|
-
const refs = new RefAllocator();
|
|
240
|
-
const resourceSecrets: EngineSecrets = {};
|
|
241
|
-
|
|
242
|
-
// DeviceManifest is split per family; accumulate each separately, attach only
|
|
243
|
-
// the non-empty ones (all slots optional).
|
|
244
|
-
const gpios: Record<string, EngineSchemas["GPIOConfig"]> = {};
|
|
245
|
-
const adcs: Record<string, EngineSchemas["ADCConfig"]> = {};
|
|
246
|
-
const dacs: Record<string, EngineSchemas["DACConfig"]> = {};
|
|
247
|
-
const pwms: Record<string, EngineSchemas["PWMConfig"]> = {};
|
|
248
|
-
const serials: Record<string, EngineSchemas["SerialConfig"]> = {};
|
|
249
|
-
|
|
250
|
-
const externalResources: EngineSchemas["ExternalResources"] = {};
|
|
251
|
-
const mapping: EngineSchemas["ResourceMapping"] = {};
|
|
252
|
-
|
|
253
|
-
// Container-level hardware access, resolved once: cdev nodes (GPIO, UART) map
|
|
254
|
-
// one-to-one into the engine component's devices; sysfs families (ADC/DAC/PWM)
|
|
255
|
-
// have no single node, so the engine container runs privileged.
|
|
256
|
-
const cdev = new Set<string>();
|
|
257
|
-
let privileged = false;
|
|
258
|
-
|
|
259
|
-
// Hardware: one driver instance per distinct device path (dedup by
|
|
260
|
-
// family+path); one mapping per channel carrying its per-channel index.
|
|
261
|
-
for (const ch of req.hardwareChannels) {
|
|
262
|
-
const b = inputs.hardware[ch.id];
|
|
263
|
-
if (!b) throw new Error(`unbound hardware channel ${ch.id}`); // unreachable after assertDeployable
|
|
264
|
-
const dev = b.chipOrDevice;
|
|
265
|
-
const ref = refs.alloc(`hw:${ch.family}:${dev}`, basename(dev));
|
|
266
|
-
|
|
267
|
-
switch (ch.family) {
|
|
268
|
-
case "gpio":
|
|
269
|
-
gpios[ref] = { chip: dev };
|
|
270
|
-
cdev.add(dev);
|
|
271
|
-
break;
|
|
272
|
-
case "serial":
|
|
273
|
-
serials[ref] = b.baud ? { device: dev, baud: b.baud } : { device: dev };
|
|
274
|
-
cdev.add(dev);
|
|
275
|
-
break;
|
|
276
|
-
case "pwm":
|
|
277
|
-
pwms[ref] = { chip: dev };
|
|
278
|
-
privileged = true;
|
|
279
|
-
break;
|
|
280
|
-
case "adc":
|
|
281
|
-
adcs[ref] = { device: dev };
|
|
282
|
-
privileged = true;
|
|
283
|
-
break;
|
|
284
|
-
case "dac":
|
|
285
|
-
dacs[ref] = { device: dev };
|
|
286
|
-
privileged = true;
|
|
287
|
-
break;
|
|
288
|
-
default:
|
|
289
|
-
return assertNeverFamily(ch.family);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
if (ch.addressable && b.index !== undefined) mapping[ch.id] = { ref, index: b.index };
|
|
293
|
-
else mapping[ch.id] = { ref };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// MQTT: one connection per distinct config (dedup by full content — same
|
|
297
|
-
// broker, different creds is a different resource). No index.
|
|
298
|
-
for (const ch of req.mqttChannels) {
|
|
299
|
-
const b = inputs.mqtt[ch.id];
|
|
300
|
-
if (!b) throw new Error(`unbound mqtt channel ${ch.id}`); // unreachable
|
|
301
|
-
const conn: EngineSchemas["MQTTConnection"] = { type: "mqtt", brokerUrl: b.brokerUrl };
|
|
302
|
-
if (b.username) conn.username = b.username;
|
|
303
|
-
// The password is a secret — kept out of conn (and thus the spec). It still
|
|
304
|
-
// participates in the dedup key, so two channels differing only by password
|
|
305
|
-
// don't collapse onto one ref (and one shared secret).
|
|
306
|
-
const ref = refs.alloc(`mqtt:${JSON.stringify(conn)}:${b.password ?? ""}`, `mqtt-${brokerHost(b.brokerUrl)}`);
|
|
307
|
-
externalResources[ref] = conn;
|
|
308
|
-
mapping[ch.id] = { ref };
|
|
309
|
-
if (b.password) resourceSecrets[ref] = b.password;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Custom models: one self-hosted provider per model id. A device model points
|
|
313
|
-
// at the sidecar we run (over the container network, no key) and gets its own
|
|
314
|
-
// llama-server component; a network model points at the operator's endpoint and
|
|
315
|
-
// runs no component here. `model` is left off — the endpoint serves under the
|
|
316
|
-
// workflow id (no upstream-name aliasing yet).
|
|
317
|
-
const llamaComponents: DeployComponent[] = [];
|
|
318
|
-
for (const m of req.customModels) {
|
|
319
|
-
const b = inputs.models[m.id];
|
|
320
|
-
if (!b) throw new Error(`unbound model ${m.id}`); // unreachable after assertDeployable
|
|
321
|
-
const ref = refs.alloc(`model:${m.id}`, basename(m.id));
|
|
322
|
-
mapping[m.id] = { ref };
|
|
323
|
-
|
|
324
|
-
if (b.location === "device") {
|
|
325
|
-
const port = b.port ?? 8080;
|
|
326
|
-
const service = sidecarServiceName(m.id);
|
|
327
|
-
externalResources[ref] = { type: "selfhosted", url: `http://${service}:${port}` };
|
|
328
|
-
// The whole config is CLI flags, so it rides in `command` (no config blob).
|
|
329
|
-
// ctx-size is frozen here — retuning it is a re-deploy, not an env edit.
|
|
330
|
-
llamaComponents.push({
|
|
331
|
-
name: service,
|
|
332
|
-
image: meta.llamaServerImage,
|
|
333
|
-
command: [
|
|
334
|
-
"--model",
|
|
335
|
-
`${COMPONENT_WORKSPACE_PATH}/${b.modelFile}`,
|
|
336
|
-
"--host",
|
|
337
|
-
"0.0.0.0",
|
|
338
|
-
"--port",
|
|
339
|
-
String(port),
|
|
340
|
-
"--ctx-size",
|
|
341
|
-
String(b.ctxSize ?? 4096),
|
|
342
|
-
],
|
|
343
|
-
// The model lives in this sidecar's own workspace; read-only, so no chown
|
|
344
|
-
// is needed (docs §5). The operator drops the GGUF in workspaceDir(service).
|
|
345
|
-
volumes: [`${workspaceDir(service)}:${COMPONENT_WORKSPACE_PATH}:ro`],
|
|
346
|
-
});
|
|
347
|
-
} else {
|
|
348
|
-
externalResources[ref] = { type: "selfhosted", url: b.url };
|
|
349
|
-
// The endpoint bearer is a secret — out of the spec, returned separately.
|
|
350
|
-
if (b.apiKey) resourceSecrets[ref] = b.apiKey;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const manifest: EngineSchemas["DeviceManifest"] = {};
|
|
355
|
-
if (Object.keys(gpios).length) manifest.gpios = gpios;
|
|
356
|
-
if (Object.keys(adcs).length) manifest.adcs = adcs;
|
|
357
|
-
if (Object.keys(dacs).length) manifest.dacs = dacs;
|
|
358
|
-
if (Object.keys(pwms).length) manifest.pwms = pwms;
|
|
359
|
-
if (Object.keys(serials).length) manifest.serials = serials;
|
|
360
|
-
|
|
361
|
-
// The engine's boot input. workflow is serialized (and thereby pruned) from
|
|
362
|
-
// the domain. The optional sections attach only when non-empty — an absent
|
|
363
|
-
// section is correct, not a gap, and matches what the engine skips.
|
|
364
|
-
const config: EngineConfig = { workflow: serialize(workflow) };
|
|
365
|
-
if (Object.keys(mapping).length) config.mapping = mapping;
|
|
366
|
-
if (Object.keys(externalResources).length) config.externalResources = externalResources;
|
|
367
|
-
if (Object.keys(manifest).length) config.manifest = manifest;
|
|
368
|
-
|
|
369
|
-
// The engine component. The config blob is mounted at the fixed convention path
|
|
370
|
-
// (component.ConfigFile) the engine image reads.
|
|
371
|
-
const engine: DeployComponent = {
|
|
372
|
-
name: "engine",
|
|
373
|
-
image: meta.engineImage,
|
|
374
|
-
pull: "never", // built locally before deploy, in no registry
|
|
375
|
-
config,
|
|
376
|
-
// Durable memory: a host bind mount under the state root, read-write, at the
|
|
377
|
-
// in-container workspace path (docs/device-filesystem.md §10).
|
|
378
|
-
volumes: [`${workspaceDir("engine")}:${COMPONENT_WORKSPACE_PATH}`],
|
|
379
|
-
// Run as root: writes the rw workspace bind mount without a host chown step
|
|
380
|
-
// (the OSS default, §5), and also opens root-owned device nodes when hardware
|
|
381
|
-
// (cdev passthrough / sysfs-privileged) is mapped in below.
|
|
382
|
-
user: "0:0",
|
|
383
|
-
};
|
|
384
|
-
if (cdev.size > 0) engine.devices = [...cdev];
|
|
385
|
-
if (privileged) engine.privileged = true;
|
|
386
|
-
|
|
387
|
-
const components = [engine, ...llamaComponents, ...customComponents];
|
|
388
|
-
assertNoNameCollisions(components);
|
|
389
|
-
|
|
390
|
-
const spec: DeploymentSpec = {
|
|
391
|
-
schemaVersion: 1,
|
|
392
|
-
id: meta.id,
|
|
393
|
-
components,
|
|
394
|
-
};
|
|
395
|
-
if (meta.createdAt) spec.createdAt = meta.createdAt;
|
|
396
|
-
return { spec, resourceSecrets };
|
|
397
|
-
}
|