@norskvideo/ctl-sdk 0.1.15 → 0.1.16

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.
@@ -21,6 +21,15 @@ export declare const ProductIframe: import("react").ForwardRefExoticComponent<{
21
21
  * don't emit it leave the field's default behaviour unchanged.
22
22
  */
23
23
  onSuggestName?: (name: string) => void;
24
+ /**
25
+ * Fires when the iframe emits a `requirements` postMessage — the product's
26
+ * suggested placement requirements for the template being configured, which
27
+ * it may recompute as the operator changes the config. The raw payload is
28
+ * forwarded (kept schema-agnostic here); the parent validates + pre-fills its
29
+ * requirements editor. Products that don't emit it leave the editor at its
30
+ * static/blank default.
31
+ */
32
+ onRequirements?: (requirements: unknown) => void;
24
33
  /**
25
34
  * Optional product-template/launch context forwarded verbatim in the `init`
26
35
  * payload (as `launchContext`). Used by the instance-launch config screen
@@ -6,7 +6,7 @@ function resolveSrc(productName, configScreenUrl) {
6
6
  const path = configScreenUrl.startsWith("/") ? configScreenUrl : `/${configScreenUrl}`;
7
7
  return `/products/${encodeURIComponent(productName)}${path}`;
8
8
  }
9
- export const ProductIframe = forwardRef(function ProductIframe({ productName, configScreenUrl, onValidityChange, onSuggestName, launchContext }, ref) {
9
+ export const ProductIframe = forwardRef(function ProductIframe({ productName, configScreenUrl, onValidityChange, onSuggestName, onRequirements, launchContext }, ref) {
10
10
  const iframeRef = useRef(null);
11
11
  const [height, setHeight] = useState(420);
12
12
  const [valid, setValid] = useState(true);
@@ -22,6 +22,10 @@ export const ProductIframe = forwardRef(function ProductIframe({ productName, co
22
22
  useEffect(() => {
23
23
  onSuggestNameRef.current = onSuggestName;
24
24
  }, [onSuggestName]);
25
+ const onRequirementsRef = useRef(onRequirements);
26
+ useEffect(() => {
27
+ onRequirementsRef.current = onRequirements;
28
+ }, [onRequirements]);
25
29
  const src = resolveSrc(productName, configScreenUrl);
26
30
  useEffect(() => {
27
31
  const onMessage = (event) => {
@@ -53,6 +57,12 @@ export const ProductIframe = forwardRef(function ProductIframe({ productName, co
53
57
  onSuggestNameRef.current?.(payload.name);
54
58
  break;
55
59
  }
60
+ case "requirements": {
61
+ // Forward the raw suggestion; the parent validates against the schema.
62
+ if ("requirements" in payload)
63
+ onRequirementsRef.current?.(payload.requirements);
64
+ break;
65
+ }
56
66
  case "submit-result": {
57
67
  const resolve = submitResolverRef.current;
58
68
  submitResolverRef.current = null;
@@ -14,6 +14,12 @@
14
14
  export type ProductTemplateBuildRequest = {
15
15
  productName: string;
16
16
  productTemplateName: string;
17
+ /** Config-screen values, forwarded verbatim to the product's build
18
+ * endpoint. May additionally carry `$advancedDefaults` — create-time
19
+ * custom defaults from the Custom defaults disclosure, in the
20
+ * ProductTemplateAdvanced per-knob `{ default }` shape — which a
21
+ * dev-kit-generated product splits off before its strict config parse
22
+ * and bakes into the manifest's `advanced` block. */
17
23
  formValues: Record<string, unknown>;
18
24
  };
19
25
  export type ProductTemplateBuildOutcome<TProductTemplate> = {
@@ -23,10 +29,13 @@ export type ProductTemplateBuildOutcome<TProductTemplate> = {
23
29
  ok: false;
24
30
  error: string;
25
31
  };
26
- export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }: {
32
+ export declare function ProductTemplateBuildForm<TProductTemplate>({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }: {
27
33
  productName: string;
28
34
  configScreenUrl: string | null;
29
35
  onClose: () => void;
36
+ /** Forwarded from the config-screen iframe: the product's suggested placement
37
+ * requirements (raw payload; the consumer validates). Optional. */
38
+ onRequirements?: (requirements: unknown) => void;
30
39
  /**
31
40
  * Persists the product template. Implementation owns the wire protocol —
32
41
  * runner stores on disk; manager submits to manager-daemon. Returns the
@@ -1,11 +1,40 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { X } from "lucide-react";
2
+ import { ChevronDown, ChevronRight, X } from "lucide-react";
3
3
  import { useRef, useState } from "react";
4
4
  import toast from "react-hot-toast";
5
5
  import { validateInstanceId } from "../validate.js";
6
6
  import { ProductIframe } from "./ProductIframe.js";
7
7
  import { useUIPrimitives } from "./ui-primitives.js";
8
- export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, }) {
8
+ const emptyAdvancedDefaults = {
9
+ networkMode: "",
10
+ hostPorts: "",
11
+ containerUser: "",
12
+ shmSize: "",
13
+ openFileLimit: "",
14
+ };
15
+ /** Assemble the disclosure's draft into the ProductTemplateAdvanced per-knob
16
+ * `{ default }` shape. Only set fields attach; undefined when untouched, so
17
+ * the build request stays byte-identical for authors who ignore it. */
18
+ function assembleAdvancedDefaults(d) {
19
+ const out = {};
20
+ if (d.networkMode)
21
+ out.networkMode = { default: d.networkMode };
22
+ const ports = d.hostPorts
23
+ .split(",")
24
+ .map((t) => t.trim())
25
+ .filter((t) => t.length > 0);
26
+ if (ports.length > 0)
27
+ out.hostPorts = { default: ports };
28
+ if (d.containerUser.trim())
29
+ out.containerUser = { default: d.containerUser.trim() };
30
+ if (d.shmSize.trim())
31
+ out.shmSize = { default: d.shmSize.trim() };
32
+ const limit = Number(d.openFileLimit);
33
+ if (d.openFileLimit.trim() && Number.isInteger(limit))
34
+ out.openFileLimit = { default: limit };
35
+ return Object.keys(out).length > 0 ? out : undefined;
36
+ }
37
+ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose, onSubmit, onSaved, onSavedAndLaunch, onRequirements, }) {
9
38
  const { Button, Input, Label } = useUIPrimitives();
10
39
  const [productTemplateName, setProductTemplateName] = useState("");
11
40
  // The iframe can suggest a name derived from its current choices (#317).
@@ -19,6 +48,9 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
19
48
  // products that don't care about validation aren't required to emit).
20
49
  const [iframeValid, setIframeValid] = useState(true);
21
50
  const iframeRef = useRef(null);
51
+ const [defaultsOpen, setDefaultsOpen] = useState(false);
52
+ const [advancedDraft, setAdvancedDraft] = useState(emptyAdvancedDefaults);
53
+ const patchDraft = (patch) => setAdvancedDraft((d) => ({ ...d, ...patch }));
22
54
  const handleSuggestName = (name) => {
23
55
  setSuggestedName(name);
24
56
  if (!nameTouched)
@@ -46,6 +78,9 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
46
78
  }
47
79
  formValues = result.formValues;
48
80
  }
81
+ const advanced = assembleAdvancedDefaults(advancedDraft);
82
+ if (advanced)
83
+ formValues = { ...formValues, $advancedDefaults: advanced };
49
84
  const outcome = await onSubmit({ productName, productTemplateName, formValues });
50
85
  if (!outcome.ok) {
51
86
  toast.error(`Save product template failed: ${outcome.error}`);
@@ -83,5 +118,5 @@ export function ProductTemplateBuildForm({ productName, configScreenUrl, onClose
83
118
  return (_jsxs("div", { className: "mb-6 rounded-lg border border-zinc-700 bg-zinc-900 p-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-4", children: [_jsxs("div", { children: [_jsxs("h4", { className: "text-sm font-semibold text-zinc-100", children: ["Create ", productName, " product template"] }), _jsx("p", { className: "text-xs text-zinc-500 mt-0.5", children: "Saves a reusable product template. Launch instances from it later." })] }), _jsx("button", { type: "button", onClick: onClose, className: "text-zinc-500 hover:text-zinc-300", children: _jsx(X, { size: 14 }) })] }), _jsxs("form", { onSubmit: handleSave, className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { children: "Product template name *" }), _jsx(Input, { value: productTemplateName, onChange: (e) => {
84
119
  setNameTouched(true);
85
120
  setProductTemplateName(e.target.value);
86
- }, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
121
+ }, placeholder: suggestedName || `e.g. ${productName}-prod`, required: true }), productTemplateNameError ? (_jsx("p", { className: "text-xs text-red-400 mt-1", children: productTemplateNameError })) : (_jsx("p", { className: "text-xs text-zinc-500 mt-1", children: "Lowercase letters, numbers, and hyphens only. Used to launch instances later." }))] }), configScreenUrl ? (_jsx(ProductIframe, { ref: iframeRef, productName: productName, configScreenUrl: configScreenUrl, onValidityChange: (v) => setIframeValid(v), onSuggestName: handleSuggestName, ...(onRequirements ? { onRequirements } : {}) })) : (_jsxs("p", { className: "text-xs text-zinc-500", children: ["Product manifest does not declare ", _jsx("code", { children: "ui.configScreenUrl" }), " \u2014 nothing to configure."] })), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setDefaultsOpen((v) => !v), className: "flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-500 hover:text-zinc-300", children: [defaultsOpen ? _jsx(ChevronDown, { size: 12 }) : _jsx(ChevronRight, { size: 12 }), "Custom defaults"] }), defaultsOpen && (_jsxs("div", { className: "mt-2", children: [_jsx("p", { className: "text-xs text-zinc-500 mb-2", children: "Launch-form defaults baked into this template's manifest. Operators can still override them per instance." }), _jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("div", { children: [_jsx(Label, { children: "Network mode" }), _jsxs("select", { "aria-label": "Network mode", value: advancedDraft.networkMode, onChange: (e) => patchDraft({ networkMode: e.target.value }), className: "block rounded border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-sm text-zinc-100", children: [_jsx("option", { value: "", children: "product default" }), _jsx("option", { value: "docker", children: "docker" }), _jsx("option", { value: "hybrid", children: "hybrid" })] })] }), _jsxs("div", { className: "min-w-[14rem] flex-1", children: [_jsx(Label, { children: "Host ports" }), _jsx(Input, { "aria-label": "Host ports", placeholder: "e.g. 1935, 5001/udp, 9000:80@svc", value: advancedDraft.hostPorts, onChange: (e) => patchDraft({ hostPorts: e.target.value }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Container user" }), _jsx(Input, { "aria-label": "Container user", placeholder: "uid:gid", value: advancedDraft.containerUser, onChange: (e) => patchDraft({ containerUser: e.target.value }), className: "w-28" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Shared memory size" }), _jsx(Input, { "aria-label": "Shared memory size", placeholder: "e.g. 8gb", value: advancedDraft.shmSize, onChange: (e) => patchDraft({ shmSize: e.target.value }), className: "w-24" })] }), _jsxs("div", { children: [_jsx(Label, { children: "Open file limit" }), _jsx(Input, { "aria-label": "Open file limit", type: "number", min: 1024, max: 1048576, placeholder: "runner default", value: advancedDraft.openFileLimit, onChange: (e) => patchDraft({ openFileLimit: e.target.value }), className: "w-32" })] })] })] }))] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "button", variant: "primary", disabled: !canSubmit, onClick: handleSaveAndLaunch, children: submitting ? "Working…" : "Save & Launch…" }), _jsx(Button, { type: "submit", variant: "default", disabled: !canSubmit, children: "Save" }), _jsx(Button, { type: "button", variant: "ghost", onClick: onClose, children: "Cancel" })] })] })] }));
87
122
  }
@@ -49,6 +49,12 @@ export type IframeChildMessage = {
49
49
  payload: {
50
50
  name: string;
51
51
  };
52
+ } | {
53
+ v: 1;
54
+ type: "requirements";
55
+ payload: {
56
+ requirements: unknown;
57
+ };
52
58
  } | {
53
59
  v: 1;
54
60
  type: "dirty";
@@ -91,8 +91,8 @@ export declare const ManifestSchema: z.ZodObject<{
91
91
  }, z.core.$strip>>>;
92
92
  }, z.core.$strip>>;
93
93
  targets: z.ZodArray<z.ZodEnum<{
94
- "norsk-ctl": "norsk-ctl";
95
94
  "docker-compose": "docker-compose";
95
+ "norsk-ctl": "norsk-ctl";
96
96
  }>>;
97
97
  components: z.ZodDefault<z.ZodArray<z.ZodObject<{
98
98
  name: z.ZodString;
@@ -102,6 +102,55 @@ export declare const ManifestSchema: z.ZodObject<{
102
102
  runtime: z.ZodDefault<z.ZodObject<{
103
103
  sharedWorkingDirectory: z.ZodDefault<z.ZodBoolean>;
104
104
  }, z.core.$strip>>;
105
+ requirements: z.ZodOptional<z.ZodObject<{
106
+ capacity: z.ZodOptional<z.ZodObject<{
107
+ mode: z.ZodEnum<{
108
+ default: "default";
109
+ fixed: "fixed";
110
+ required: "required";
111
+ }>;
112
+ value: z.ZodOptional<z.ZodNumber>;
113
+ }, z.core.$strip>>;
114
+ cores: z.ZodOptional<z.ZodObject<{
115
+ mode: z.ZodEnum<{
116
+ default: "default";
117
+ fixed: "fixed";
118
+ required: "required";
119
+ }>;
120
+ value: z.ZodOptional<z.ZodNumber>;
121
+ }, z.core.$strip>>;
122
+ gpu: z.ZodOptional<z.ZodObject<{
123
+ mode: z.ZodEnum<{
124
+ default: "default";
125
+ fixed: "fixed";
126
+ required: "required";
127
+ }>;
128
+ value: z.ZodOptional<z.ZodObject<{
129
+ capacity: z.ZodOptional<z.ZodNumber>;
130
+ model: z.ZodOptional<z.ZodString>;
131
+ }, z.core.$strip>>;
132
+ }, z.core.$strip>>;
133
+ capabilities: z.ZodOptional<z.ZodObject<{
134
+ mode: z.ZodEnum<{
135
+ default: "default";
136
+ fixed: "fixed";
137
+ required: "required";
138
+ }>;
139
+ value: z.ZodOptional<z.ZodArray<z.ZodObject<{
140
+ name: z.ZodString;
141
+ count: z.ZodOptional<z.ZodNumber>;
142
+ attributeMatches: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
143
+ }, z.core.$strip>>>;
144
+ }, z.core.$strip>>;
145
+ hostPorts: z.ZodOptional<z.ZodObject<{
146
+ mode: z.ZodEnum<{
147
+ default: "default";
148
+ fixed: "fixed";
149
+ required: "required";
150
+ }>;
151
+ value: z.ZodOptional<z.ZodArray<z.ZodString>>;
152
+ }, z.core.$strip>>;
153
+ }, z.core.$strip>>;
105
154
  defaultProductTemplates: z.ZodDefault<z.ZodArray<z.ZodObject<{
106
155
  name: z.ZodString;
107
156
  url: z.ZodString;
@@ -1,3 +1,4 @@
1
+ import { ProductTemplateRequirementsSchema } from "@norskvideo/ctl-product-template-schema";
1
2
  import { z } from "zod";
2
3
  const TargetSchema = z.enum(["norsk-ctl", "docker-compose"]);
3
4
  const SidebarEntrySchema = z.object({
@@ -137,6 +138,9 @@ export const ManifestSchema = z.object({
137
138
  targets: z.array(TargetSchema),
138
139
  components: z.array(ComponentDescSchema).default([]),
139
140
  runtime: RuntimeHintsSchema,
141
+ requirements: ProductTemplateRequirementsSchema.optional().meta({
142
+ description: "Optional default placement requirements the product suggests (requirements-authoring.md). Same fixed/default/required shape as a product template's manifest — norsk-mgr pre-fills the template + instance requirements editors from it, and the operator can edit. Omit if the product expresses no requirements (the editors default to blank). Products may also refine these live via the config-screen iframe.",
143
+ }),
140
144
  defaultProductTemplates: z.array(DefaultProductTemplateSchema).default([]).meta({
141
145
  description: "Product templates the runner auto-fetches at product-registration time. Each entry is GET'd against the product's base URL and stored under its `name` via the runner's product-template store. Lets a product offer 'zero-config' launch paths — operators add the product and immediately have a launchable product template without visiting configure.",
142
146
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-sdk",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -47,6 +47,12 @@ export function parseProductTemplatesFile(raw, path) {
47
47
  };
48
48
  if (typeof entry.sha256 === "string")
49
49
  rec.sha256 = entry.sha256;
50
+ if (isRecord(entry.launchDefaults))
51
+ rec.launchDefaults = entry.launchDefaults;
52
+ if (Array.isArray(entry.suggestedSecurityGroups) &&
53
+ entry.suggestedSecurityGroups.every((l) => typeof l === "string")) {
54
+ rec.suggestedSecurityGroups = entry.suggestedSecurityGroups;
55
+ }
50
56
  if (isRecord(entry.source)) {
51
57
  if (entry.source.kind === "import" && typeof entry.source.fromFile === "string") {
52
58
  rec.source = { kind: "import", fromFile: entry.source.fromFile };
@@ -42,4 +42,17 @@ export interface ProductTemplateRecord {
42
42
  /** Lowercase hex SHA-256 of the source tar bytes. Optional for
43
43
  * historic records that predate the field. */
44
44
  sha256?: string;
45
+ /** Template-author launch defaults — the fusion tier between the
46
+ * manifest's advanced defaults and operator intent
47
+ * (placement-requirements.md §7). Shape is norsk-ctl's
48
+ * ProductTemplateLaunchConfig; validated by the store's writer and
49
+ * authoritatively by the worker at launch. Rides launches as
50
+ * template_launch_defaults_json. */
51
+ launchDefaults?: Record<string, unknown>;
52
+ /** Template-author security-group suggestions, by discovery-tag LABEL
53
+ * (portable across deployments; ids are not). The launch UI pre-ticks
54
+ * discovered groups whose label matches — a suggestion the operator
55
+ * can untick, never a pin, and never a wire field of its own: the
56
+ * confirmed selection rides the job as extraSecurityGroups ids. */
57
+ suggestedSecurityGroups?: string[];
45
58
  }