From 94c0d376a118516448ff4f111319e1e4373bd320 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Mon, 9 Nov 2026 20:50:00 +0800 Subject: [PATCH 1/7] feat(schema): requires.inputs, chart values a variant leaves to the deploy A variant declares the chart values it needs and cannot know (a Mooncake master address, say) as requires.inputs: path, title, type, pattern and an example. values must leave each input unset; validate:helm renders with the example. swiss answers them from the site profile's features.inputs, and the deployer can override any of them. docs/requires-design.md has the design and the rollout order: swiss and the console ship first, since an older swissd refuses the unknown key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- docs/requires-design.md | 134 +++++++++++++++++++++++++++++++ hack/lib/helm-validation.js | 6 ++ hack/lib/helm-validation.test.js | 13 +++ hack/lib/inputs.js | 52 ++++++++++++ hack/lib/inputs.test.js | 52 ++++++++++++ hack/validate-schema.js | 2 + schema/version.schema.json | 42 ++++++++++ web/src/types.ts | 11 +++ 8 files changed, 312 insertions(+) create mode 100644 docs/requires-design.md create mode 100644 hack/lib/inputs.js create mode 100644 hack/lib/inputs.test.js diff --git a/docs/requires-design.md b/docs/requires-design.md new file mode 100644 index 0000000..8d3ef67 --- /dev/null +++ b/docs/requires-design.md @@ -0,0 +1,134 @@ +# Variant inputs + +Status: **draft** · 2026-11-09 +Touches: model-catalog (schema, validator), swiss (catalog, site profile, compose), console (deploy form) + +## 1. Summary + +A variant's `values` are its defaults. Some values a variant needs but cannot +know, such as the address of a service the site runs. The variant declares +those in `requires.inputs`, and swiss answers each one at two levels: + +1. **Site**: the profile's `features.inputs`, keyed by chart path, with + templates. These answers go into the derived layer. +2. **Deployer**: the deploy form shows every input with the site's answer as + its default, and the deployer can override any of them. Overrides go into + the form layer. + +Compose refuses a plan that leaves an input unanswered. The workflow stays +compose → diff → apply, and swiss's API and plan format do not change. + +`model.localPath` already works this way for one fixed value: the site's +`model.pathTemplate` answers it, and the deploy form's `localPath` overrides it. +Inputs make the same pattern declarable for any chart value. + +## 2. Why + +A variant has no way to say "supplied at deploy", so today it writes a +placeholder or one cluster's value instead: + +| Variant | Value | +|---|---| +| `deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d` | `storageKVOffload.mooncake.master: mooncake-master.example:50051` | +| `deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d` | the same placeholder | +| `glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d` | `storageKVOffload.mooncake.master: ""` | + +## 3. Catalog: `requires.inputs` + +Added to `$defs.variant.properties.requires` in `schema/version.schema.json`. +The v2 variant and index schemas reference that definition, so they pick it up. + +```yaml +requires: + gpus: 8 + inputs: + - path: storageKVOffload.mooncake.master # the chart value, dotted + title: Mooncake master + description: host:port of the Mooncake Store master this KV tier uses. + type: string # string (default), integer or boolean + pattern: '[^\s:/]+:[0-9]+' # a string answer matches it, whole + example: mooncake-master.example:50051 # required; never deployed +``` + +- **`example`** is the form's placeholder and the value CI renders the chart + with. It is never deployed. +- **`model.localPath` cannot be declared**: swiss already answers it from + `model.pathTemplate`. +- **Every input is required.** A value that's fine to leave as is belongs in + `values`. + +**Validator** (`hack/validate-schema.js`), for every v2 variant: + +- paths are unique within a variant; +- `values` sets nothing at an input's path, or under it; +- `pattern` compiles, and appears only on a string input; +- `example` matches the input's type and pattern. + +**validate:helm**: `catalogValues` sets each input to its `example`, so CI +renders every path that swiss would fill at deploy. + +## 4. Site profile: `features.inputs` + +```yaml +features: + inputs: + storageKVOffload.mooncake.master: mooncake-master.svc:50051 + storageKVOffload.mooncake.te_meta_data_server: http://mooncake-master.svc:8080/metadata +``` + +- **Keys are chart paths.** An answer applies only to a variant that declares + that path. That is the difference from `extra`, which lands in every deploy. +- **Templates.** A string answer can use the placeholders `model.pathTemplate` + already supports: `{{hf}}`, `{{org}}`, `{{name}}` and `{{model}}`. Parsing + refuses a profile with an unknown placeholder. +- **One answer per path.** Parsing refuses a profile that sets the same path in + both `extra` and `features.inputs`. +- **Separate from `defaults.features`**, which holds the site's on/off defaults + for chart features. + +## 5. swiss + +- **Catalog**: `catalog.Requires` gains `Inputs`. swiss repeats the validator's + rules when it loads a variant, because the catalog is untrusted input where + it is used. +- **Profile**: `site.Profile` gains `Features.Inputs`, checked as in §4. +- **Compose**: + 1. For each declared input, the site's answer, with its template expanded, + goes into the derived layer, unless the catalog or site layer already sets + the path. + 2. Once all layers are assembled, each declared input must have a value of + its type that matches its pattern. If not, compose fails, for example: + `storageKVOffload.mooncake.master (Mooncake master) is not set: set it in + the deploy's overrides, or features.inputs in the site profile`. +- **Upgrade**: `carryForward` keeps the form layer, so the deployer's answers + persist. Site answers are derived again from the current profile. + +## 6. Console: the deploy form + +- **Renders every declared input**: its title and description, the example as + a placeholder, and the pattern as client-side validation. +- **Prefills the site's answer**, from the profile the form already reads, with + the template expanded using the same placeholders. +- **Sends only the inputs the deployer changed**, as overrides at their paths. + An untouched field stays a site answer, so a profile change reaches the + release on its next upgrade. +- **The upgrade form** shows the release's own answers from its form layer. + +## 7. Rollout + +1. **swiss and the console**, on every site. An older swissd refuses unknown + keys in a v2 variant and in the v2 index, and the index would fail whole. +2. **model-catalog**: the schema, the validator and validate:helm. This can + land at any time, because no variant uses inputs yet. +3. **Sites**: set `features.inputs`. +4. **Variants**: replace the placeholders in §2 with declared inputs, one + commit, each variant bumping its patch version. + +## 8. Later + +- **Inputs inside lists** (`env`, `extraArgs`): an input path cannot address + one list item. A standalone chart fix (map-shaped `env` and `extraArgs`) + handles this. After it lands, the host NIC (13 variants) and the Mooncake + settings in `kimi-k3/vllm-tp8-b300`'s env become inputs. +- **Other site-specific values found in variants**: the RDMA device resource, + EBOF placement, HCA names, engine-cache hostPaths and Ascend runtime mounts. diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 3d446fc..817b753 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -121,6 +121,11 @@ function parseVersions(file) { // (catalogLayer in swiss/internal/compose): its values, with the fields the // variant spells first-class projected on top, so they win over the same keys // in values. image.digest is not projected. +// +// Each declared input is set to its example, standing in for the answer a +// deploy gives (the site's features.inputs or the deploy's overrides), so the +// chart renders every path swiss fills. values never sets one; validate:schema +// refuses that. function catalogValues(variant) { const values = structuredClone(variant.values ?? {}); const set = (keys, value) => { @@ -136,6 +141,7 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } + for (const input of variant.requires?.inputs ?? []) set(input.path.split("."), input.example); return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index ff2abd2..13e8d11 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -196,3 +196,16 @@ test("variant values get swiss's projection of servedName, requires.gpus and the const [v2] = parseVersions(path.join(dir, "v2.yaml")); assert.deepEqual(catalogValues(v2), { model: { name: "served", gpus: "2" } }); }); + +test("each declared input renders with its example, beside the values around it", () => { + const variant = { + servedName: "m", + requires: { gpus: 8, inputs: [{ path: "storageKVOffload.mooncake.master", title: "Mooncake master", example: "mooncake-master.example:50051" }] }, + values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, + }; + assert.deepEqual(catalogValues(variant).storageKVOffload, { + enabled: true, + mooncake: { protocol: "rdma", master: "mooncake-master.example:50051" }, + }); + assert.equal(variant.values.storageKVOffload.mooncake.master, undefined); +}); diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js new file mode 100644 index 0000000..22cff04 --- /dev/null +++ b/hack/lib/inputs.js @@ -0,0 +1,52 @@ +"use strict"; +// requires.inputs, past what the schema can see. A path is declared once, +// values leaves it to the deploy -- an input with a default is not an input -- +// and the example is a valid answer, since CI renders the chart with it. swiss +// repeats these checks when it loads a variant (internal/catalog). + +// Problems with one variant's inputs, each line prefixed with at. +function inputProblems(variant, at = "") { + const inputs = Array.isArray(variant?.requires?.inputs) ? variant.requires.inputs : []; + const lines = []; + const declared = new Set(); + for (const [i, input] of inputs.entries()) { + if (typeof input?.path !== "string") continue; // the schema reports it + const where = `${at}requires.inputs[${i}] ${input.path}: `; + if (declared.has(input.path)) lines.push(`${where}declared twice`); + declared.add(input.path); + const set = valueSetAt(variant.values, input.path.split(".")); + if (set) lines.push(`${where}values.${set} sets it; values leaves an input unset, for the deploy to answer`); + const type = input.type ?? "string"; + let pattern; + if (input.pattern !== undefined && type !== "string") { + lines.push(`${where}pattern is only for a string input, and this one is ${type}`); + } else if (input.pattern !== undefined) { + try { + pattern = new RegExp(`^(?:${input.pattern})$`, "u"); + } catch (err) { + lines.push(`${where}pattern does not compile: ${err.message}`); + } + } + if ("example" in input) { + const ok = type === "integer" ? Number.isInteger(input.example) : typeof input.example === type; + if (!ok) lines.push(`${where}example ${JSON.stringify(input.example)} is not ${type === "integer" ? "an" : "a"} ${type}`); + else if (pattern && !pattern.test(input.example)) lines.push(`${where}example ${JSON.stringify(input.example)} does not match the pattern`); + } + } + return lines; +} + +// The prefix of keys at which values already says something about a path: +// the value itself, a value under it, or a non-map on the way to it. Empty +// when values leaves the path alone. +function valueSetAt(values, keys) { + let node = values; + for (const [i, key] of keys.entries()) { + if (node === null || typeof node !== "object" || Array.isArray(node)) return i > 0 ? keys.slice(0, i).join(".") : ""; + if (!(key in node)) return ""; + node = node[key]; + } + return keys.join("."); +} + +module.exports = { inputProblems }; diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js new file mode 100644 index 0000000..9c4dfe4 --- /dev/null +++ b/hack/lib/inputs.test.js @@ -0,0 +1,52 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { inputProblems } = require("./inputs"); + +const master = { + path: "storageKVOffload.mooncake.master", + title: "Mooncake master", + pattern: "[^\\s:/]+:[0-9]+", + example: "mooncake-master.example:50051", +}; +const variant = (inputs, values = {}) => ({ requires: { gpus: 8, inputs }, values }); + +test("a declared input that values leaves alone, with a valid example, passes", () => { + assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } })), []); + assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); +}); + +test("values may not answer an input: at its path, under it, or with a non-map on the way", () => { + for (const [values, at] of [ + [{ storageKVOffload: { mooncake: { master: "" } } }, "storageKVOffload.mooncake.master"], + [{ storageKVOffload: { mooncake: { master: { host: "h" } } } }, "storageKVOffload.mooncake.master"], + [{ storageKVOffload: { mooncake: "off" } }, "storageKVOffload.mooncake"], + ]) { + const [line, ...rest] = inputProblems(variant([master], values), "v: "); + assert.equal(rest.length, 0); + assert.match(line, new RegExp(`^v: requires\\.inputs\\[0\\] storageKVOffload\\.mooncake\\.master: values\\.${at.replace(/\./g, "\\.")} sets it`)); + } +}); + +test("a path is declared once", () => { + assert.deepEqual(inputProblems(variant([master, master])), ["requires.inputs[1] storageKVOffload.mooncake.master: declared twice"]); +}); + +test("the example is an answer: of the input's type, matching its pattern", () => { + const one = (input) => inputProblems(variant([{ path: "a.b", title: "t", ...input }])); + assert.deepEqual(one({ example: "mooncake-master:50051", pattern: master.pattern }), []); + assert.match(one({ example: "no-port", pattern: master.pattern })[0], /example "no-port" does not match the pattern$/); + assert.match(one({ example: "8", type: "integer" })[0], /example "8" is not an integer$/); + assert.match(one({ example: 1.5, type: "integer" })[0], /is not an integer$/); + assert.deepEqual(one({ example: 8, type: "integer" }), []); + assert.deepEqual(one({ example: false, type: "boolean" }), []); + assert.match(one({ example: 8 })[0], /example 8 is not a string$/); +}); + +test("a pattern compiles, and only a string input has one", () => { + assert.match(inputProblems(variant([{ ...master, pattern: "(" }]))[0], /pattern does not compile/); + assert.match( + inputProblems(variant([{ path: "a.b", title: "t", type: "integer", pattern: "[0-9]+", example: 1 }]))[0], + /pattern is only for a string input, and this one is integer$/, + ); +}); diff --git a/hack/validate-schema.js b/hack/validate-schema.js index baa6c77..857e5cb 100644 --- a/hack/validate-schema.js +++ b/hack/validate-schema.js @@ -27,6 +27,7 @@ const { checkIndexV2, } = require("./lib/catalog"); const { reportProblem } = require("./lib/report"); +const { inputProblems } = require("./lib/inputs"); const { paint, marks, plural } = require("./lib/style"); const { @@ -225,6 +226,7 @@ function variantProblems(v, at) { if (v.requires[key] !== undefined) lines.push(`${at}requires.${key} is set, but requires.gpus 0 is CPU only`); } } + lines.push(...inputProblems(v, at)); return lines; } diff --git a/schema/version.schema.json b/schema/version.schema.json index ee2bb9c..4ab57f8 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -145,6 +145,48 @@ "description": "Accelerator brand this variant is built for. Decides the extended resource the pod requests and the node label its product is published under -- nvidia.com/gpu and nvidia.com/gpu.product, huawei.com/Ascend910 and its own label, and so on. Singular on purpose: a CANN build of an engine is a different image with different flags than a CUDA build, so a model that runs on both publishes two variants rather than one variant claiming both.", "enum": ["nvidia", "ascend", "cambricon", "hygon", "amd"], "default": "nvidia" + }, + "inputs": { + "description": "Chart values this variant needs and cannot know, such as the address of a service the site runs. Each is answered at deploy -- by the site profile's features.inputs, which the deploy can override -- and swiss refuses to compose a plan that leaves one unset. A value that may stay as it is belongs in values, and values sets no input. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "title", "example"], + "properties": { + "path": { + "description": "The chart value, as a dotted path: storageKVOffload.mooncake.master. Unique within the variant.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]*(\\.[A-Za-z_][A-Za-z0-9_-]*)*$", + "not": { "enum": ["model.localPath"] } + }, + "title": { + "description": "What the deploy form labels the field with.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "What the value is, for the deploy form.", + "type": "string", + "minLength": 1 + }, + "type": { + "description": "What an answer is.", + "enum": ["string", "integer", "boolean"], + "default": "string" + }, + "pattern": { + "description": "A regular expression a string answer matches, whole. Keep to the syntax Go and JavaScript read alike: swiss checks it, and so does the deploy form.", + "type": "string", + "minLength": 1 + }, + "example": { + "description": "A value of the right shape, never a real one: the deploy form's placeholder, and what CI renders the chart with. Never deployed. Must be of the input's type and match its pattern.", + "type": ["string", "integer", "boolean"] + } + } + } } }, "allOf": [ diff --git a/web/src/types.ts b/web/src/types.ts index bcb9b2e..0941705 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -8,6 +8,17 @@ export interface Requires { vendor?: string; gpuProduct?: string[]; topology?: string; + inputs?: Input[]; +} + +// A chart value the variant needs and cannot know, answered at deploy. +export interface Input { + path: string; + title: string; + description?: string; + type?: "string" | "integer" | "boolean"; + pattern?: string; + example: string | number | boolean; } export interface Chart { From b48aa5f32843b76539015e120f1f077929ef6634 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 10:44:51 +0800 Subject: [PATCH 2/7] feat(schema): an input may keep a default in values A value values gives an input's path is now its default, checked against the input's type and pattern, rather than refused. "" or null is no default, and such an input stays required. validate:helm renders the example only where there is no default. So a variant can declare an input over the value of the cluster it was tuned on, without dropping it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- hack/lib/helm-validation.js | 15 ++++++----- hack/lib/helm-validation.test.js | 16 +++++++++--- hack/lib/inputs.js | 44 ++++++++++++++++++++------------ hack/lib/inputs.test.js | 25 ++++++++++-------- schema/version.schema.json | 2 +- web/src/types.ts | 2 +- 6 files changed, 64 insertions(+), 40 deletions(-) diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 817b753..c4678b1 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -5,12 +5,13 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const YAML = require("yaml"); const { modelNames, versionFiles, variantFiles, kindOf } = require("./catalog"); +const { hasDefault, valueAt } = require("./inputs"); function affectsValidator(file) { return ( file.startsWith("schema/crds/") || /^hack\/(validate-helm|install-helm-tools|build-crds)(\.integration)?\.(js|test\.js)$/.test(file) || - /^hack\/lib\/(helm-validation|crd-schema|download|catalog)(\.test)?\.js$/.test(file) || + /^hack\/lib\/(helm-validation|crd-schema|download|catalog|inputs)(\.test)?\.js$/.test(file) || ["package.json", "package-lock.json", ".github/workflows/lint.yml"].includes(file) ); } @@ -122,10 +123,9 @@ function parseVersions(file) { // variant spells first-class projected on top, so they win over the same keys // in values. image.digest is not projected. // -// Each declared input is set to its example, standing in for the answer a -// deploy gives (the site's features.inputs or the deploy's overrides), so the -// chart renders every path swiss fills. values never sets one; validate:schema -// refuses that. +// A declared input without a default is set to its example, standing in for +// the answer a deploy gives (the site's features.inputs or the deploy's +// overrides), so the chart renders every path swiss fills. function catalogValues(variant) { const values = structuredClone(variant.values ?? {}); const set = (keys, value) => { @@ -141,7 +141,10 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } - for (const input of variant.requires?.inputs ?? []) set(input.path.split("."), input.example); + for (const input of variant.requires?.inputs ?? []) { + const keys = input.path.split("."); + if (!hasDefault(valueAt(values, keys).value)) set(keys, input.example); + } return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index 13e8d11..9d9afaa 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -197,15 +197,23 @@ test("variant values get swiss's projection of servedName, requires.gpus and the assert.deepEqual(catalogValues(v2), { model: { name: "served", gpus: "2" } }); }); -test("each declared input renders with its example, beside the values around it", () => { +test("a declared input renders with its default, else its example", () => { + const input = (path) => ({ path, title: "t", example: "mooncake-master.example:50051" }); const variant = { servedName: "m", - requires: { gpus: 8, inputs: [{ path: "storageKVOffload.mooncake.master", title: "Mooncake master", example: "mooncake-master.example:50051" }] }, - values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, + requires: { + gpus: 8, + inputs: [ + input("storageKVOffload.mooncake.master"), + input("storageKVOffload.mooncake.te_meta_data_server"), + input("storageKVOffload.mooncake.device"), + ], + }, + values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma", te_meta_data_server: "", device: "mlx5_3" } } }, }; assert.deepEqual(catalogValues(variant).storageKVOffload, { enabled: true, - mooncake: { protocol: "rdma", master: "mooncake-master.example:50051" }, + mooncake: { protocol: "rdma", master: "mooncake-master.example:50051", te_meta_data_server: "mooncake-master.example:50051", device: "mlx5_3" }, }); assert.equal(variant.values.storageKVOffload.mooncake.master, undefined); }); diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index 22cff04..5f59d52 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,8 +1,8 @@ "use strict"; -// requires.inputs, past what the schema can see. A path is declared once, -// values leaves it to the deploy -- an input with a default is not an input -- -// and the example is a valid answer, since CI renders the chart with it. swiss -// repeats these checks when it loads a variant (internal/catalog). +// requires.inputs, past what the schema can see. A path is declared once; a +// value values gives it is its default, and like the example it must be a valid +// answer ("" or null is no default). swiss repeats these checks when it loads a +// variant (internal/catalog). // Problems with one variant's inputs, each line prefixed with at. function inputProblems(variant, at = "") { @@ -14,8 +14,6 @@ function inputProblems(variant, at = "") { const where = `${at}requires.inputs[${i}] ${input.path}: `; if (declared.has(input.path)) lines.push(`${where}declared twice`); declared.add(input.path); - const set = valueSetAt(variant.values, input.path.split(".")); - if (set) lines.push(`${where}values.${set} sets it; values leaves an input unset, for the deploy to answer`); const type = input.type ?? "string"; let pattern; if (input.pattern !== undefined && type !== "string") { @@ -27,26 +25,38 @@ function inputProblems(variant, at = "") { lines.push(`${where}pattern does not compile: ${err.message}`); } } + const answerProblem = (v) => { + const ok = type === "integer" ? Number.isInteger(v) : typeof v === type; + if (!ok) return `${JSON.stringify(v)} is not ${type === "integer" ? "an" : "a"} ${type}`; + if (pattern && !pattern.test(v)) return `${JSON.stringify(v)} does not match the pattern`; + return ""; + }; if ("example" in input) { - const ok = type === "integer" ? Number.isInteger(input.example) : typeof input.example === type; - if (!ok) lines.push(`${where}example ${JSON.stringify(input.example)} is not ${type === "integer" ? "an" : "a"} ${type}`); - else if (pattern && !pattern.test(input.example)) lines.push(`${where}example ${JSON.stringify(input.example)} does not match the pattern`); + const problem = answerProblem(input.example); + if (problem) lines.push(`${where}example ${problem}`); + } + const found = valueAt(variant.values, input.path.split(".")); + if (found.blocked) { + lines.push(`${where}values.${found.blocked} is not a map, so it cannot hold this input`); + } else if (hasDefault(found.value)) { + const problem = answerProblem(found.value); + if (problem) lines.push(`${where}default ${problem}`); } } return lines; } -// The prefix of keys at which values already says something about a path: -// the value itself, a value under it, or a non-map on the way to it. Empty -// when values leaves the path alone. -function valueSetAt(values, keys) { +const hasDefault = (v) => v !== undefined && v !== null && v !== ""; + +// What values holds at a path: { value }, or { blocked } naming a non-map on the way. +function valueAt(values, keys) { let node = values; for (const [i, key] of keys.entries()) { - if (node === null || typeof node !== "object" || Array.isArray(node)) return i > 0 ? keys.slice(0, i).join(".") : ""; - if (!(key in node)) return ""; + if (node === null || typeof node !== "object" || Array.isArray(node)) return i > 0 ? { blocked: keys.slice(0, i).join(".") } : {}; + if (!Object.hasOwn(node, key)) return {}; node = node[key]; } - return keys.join("."); + return { value: node }; } -module.exports = { inputProblems }; +module.exports = { inputProblems, hasDefault, valueAt }; diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 9c4dfe4..992d41e 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -11,21 +11,24 @@ const master = { }; const variant = (inputs, values = {}) => ({ requires: { gpus: 8, inputs }, values }); -test("a declared input that values leaves alone, with a valid example, passes", () => { +test("a declared input with a valid example passes, with or without a default", () => { assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } })), []); + for (const def of ["mc:50051", "", null]) { + assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: { master: def } } })), []); + } assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); }); -test("values may not answer an input: at its path, under it, or with a non-map on the way", () => { - for (const [values, at] of [ - [{ storageKVOffload: { mooncake: { master: "" } } }, "storageKVOffload.mooncake.master"], - [{ storageKVOffload: { mooncake: { master: { host: "h" } } } }, "storageKVOffload.mooncake.master"], - [{ storageKVOffload: { mooncake: "off" } }, "storageKVOffload.mooncake"], - ]) { - const [line, ...rest] = inputProblems(variant([master], values), "v: "); - assert.equal(rest.length, 0); - assert.match(line, new RegExp(`^v: requires\\.inputs\\[0\\] storageKVOffload\\.mooncake\\.master: values\\.${at.replace(/\./g, "\\.")} sets it`)); - } +test("values' default for an input is an answer, and nothing in values is in its way", () => { + const one = (values, input = master) => inputProblems(variant([input], values), "v: "); + assert.deepEqual(one({ storageKVOffload: { mooncake: { master: "no-port" } } }), [ + 'v: requires.inputs[0] storageKVOffload.mooncake.master: default "no-port" does not match the pattern', + ]); + assert.match(one({ storageKVOffload: { mooncake: { master: { host: "h" } } } })[0], /default \{"host":"h"\} is not a string$/); + assert.match(one({ a: { b: "8" } }, { path: "a.b", title: "t", type: "integer", example: 1 })[0], /default "8" is not an integer$/); + assert.deepEqual(one({ storageKVOffload: { mooncake: "off" } }), [ + "v: requires.inputs[0] storageKVOffload.mooncake.master: values.storageKVOffload.mooncake is not a map, so it cannot hold this input", + ]); }); test("a path is declared once", () => { diff --git a/schema/version.schema.json b/schema/version.schema.json index 4ab57f8..60b2fe8 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -147,7 +147,7 @@ "default": "nvidia" }, "inputs": { - "description": "Chart values this variant needs and cannot know, such as the address of a service the site runs. Each is answered at deploy -- by the site profile's features.inputs, which the deploy can override -- and swiss refuses to compose a plan that leaves one unset. A value that may stay as it is belongs in values, and values sets no input. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "description": "Chart values a deploy is asked for, such as the address of a service the site runs. A value values gives the path is its default; the site profile's features.inputs answers over it, and the deploy's overrides over both. swiss refuses to compose a plan that leaves one with no default unanswered (\"\" is none). model.localPath is never one: swiss answers it from the site's model.pathTemplate.", "type": "array", "minItems": 1, "items": { diff --git a/web/src/types.ts b/web/src/types.ts index 0941705..de1a23d 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -11,7 +11,7 @@ export interface Requires { inputs?: Input[]; } -// A chart value the variant needs and cannot know, answered at deploy. +// A chart value the deploy is asked for; values may give it a default. export interface Input { path: string; title: string; From 7a19ed0b6cb12b8b34a501772844148896827a6a Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 10:58:45 +0800 Subject: [PATCH 3/7] chore: keep the inputs design doc out of the repo Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- docs/requires-design.md | 134 ---------------------------------------- 1 file changed, 134 deletions(-) delete mode 100644 docs/requires-design.md diff --git a/docs/requires-design.md b/docs/requires-design.md deleted file mode 100644 index 8d3ef67..0000000 --- a/docs/requires-design.md +++ /dev/null @@ -1,134 +0,0 @@ -# Variant inputs - -Status: **draft** · 2026-11-09 -Touches: model-catalog (schema, validator), swiss (catalog, site profile, compose), console (deploy form) - -## 1. Summary - -A variant's `values` are its defaults. Some values a variant needs but cannot -know, such as the address of a service the site runs. The variant declares -those in `requires.inputs`, and swiss answers each one at two levels: - -1. **Site**: the profile's `features.inputs`, keyed by chart path, with - templates. These answers go into the derived layer. -2. **Deployer**: the deploy form shows every input with the site's answer as - its default, and the deployer can override any of them. Overrides go into - the form layer. - -Compose refuses a plan that leaves an input unanswered. The workflow stays -compose → diff → apply, and swiss's API and plan format do not change. - -`model.localPath` already works this way for one fixed value: the site's -`model.pathTemplate` answers it, and the deploy form's `localPath` overrides it. -Inputs make the same pattern declarable for any chart value. - -## 2. Why - -A variant has no way to say "supplied at deploy", so today it writes a -placeholder or one cluster's value instead: - -| Variant | Value | -|---|---| -| `deepseek-v4-flash-0731-nvfp4/sglang-tp8-l3-6000d` | `storageKVOffload.mooncake.master: mooncake-master.example:50051` | -| `deepseek-v4-flash-0731-nvfp4/sglang-tp8-pd-l3-6000d` | the same placeholder | -| `glm5.3/sglang-tp8-pp2-ep8-pd-l3-6000d` | `storageKVOffload.mooncake.master: ""` | - -## 3. Catalog: `requires.inputs` - -Added to `$defs.variant.properties.requires` in `schema/version.schema.json`. -The v2 variant and index schemas reference that definition, so they pick it up. - -```yaml -requires: - gpus: 8 - inputs: - - path: storageKVOffload.mooncake.master # the chart value, dotted - title: Mooncake master - description: host:port of the Mooncake Store master this KV tier uses. - type: string # string (default), integer or boolean - pattern: '[^\s:/]+:[0-9]+' # a string answer matches it, whole - example: mooncake-master.example:50051 # required; never deployed -``` - -- **`example`** is the form's placeholder and the value CI renders the chart - with. It is never deployed. -- **`model.localPath` cannot be declared**: swiss already answers it from - `model.pathTemplate`. -- **Every input is required.** A value that's fine to leave as is belongs in - `values`. - -**Validator** (`hack/validate-schema.js`), for every v2 variant: - -- paths are unique within a variant; -- `values` sets nothing at an input's path, or under it; -- `pattern` compiles, and appears only on a string input; -- `example` matches the input's type and pattern. - -**validate:helm**: `catalogValues` sets each input to its `example`, so CI -renders every path that swiss would fill at deploy. - -## 4. Site profile: `features.inputs` - -```yaml -features: - inputs: - storageKVOffload.mooncake.master: mooncake-master.svc:50051 - storageKVOffload.mooncake.te_meta_data_server: http://mooncake-master.svc:8080/metadata -``` - -- **Keys are chart paths.** An answer applies only to a variant that declares - that path. That is the difference from `extra`, which lands in every deploy. -- **Templates.** A string answer can use the placeholders `model.pathTemplate` - already supports: `{{hf}}`, `{{org}}`, `{{name}}` and `{{model}}`. Parsing - refuses a profile with an unknown placeholder. -- **One answer per path.** Parsing refuses a profile that sets the same path in - both `extra` and `features.inputs`. -- **Separate from `defaults.features`**, which holds the site's on/off defaults - for chart features. - -## 5. swiss - -- **Catalog**: `catalog.Requires` gains `Inputs`. swiss repeats the validator's - rules when it loads a variant, because the catalog is untrusted input where - it is used. -- **Profile**: `site.Profile` gains `Features.Inputs`, checked as in §4. -- **Compose**: - 1. For each declared input, the site's answer, with its template expanded, - goes into the derived layer, unless the catalog or site layer already sets - the path. - 2. Once all layers are assembled, each declared input must have a value of - its type that matches its pattern. If not, compose fails, for example: - `storageKVOffload.mooncake.master (Mooncake master) is not set: set it in - the deploy's overrides, or features.inputs in the site profile`. -- **Upgrade**: `carryForward` keeps the form layer, so the deployer's answers - persist. Site answers are derived again from the current profile. - -## 6. Console: the deploy form - -- **Renders every declared input**: its title and description, the example as - a placeholder, and the pattern as client-side validation. -- **Prefills the site's answer**, from the profile the form already reads, with - the template expanded using the same placeholders. -- **Sends only the inputs the deployer changed**, as overrides at their paths. - An untouched field stays a site answer, so a profile change reaches the - release on its next upgrade. -- **The upgrade form** shows the release's own answers from its form layer. - -## 7. Rollout - -1. **swiss and the console**, on every site. An older swissd refuses unknown - keys in a v2 variant and in the v2 index, and the index would fail whole. -2. **model-catalog**: the schema, the validator and validate:helm. This can - land at any time, because no variant uses inputs yet. -3. **Sites**: set `features.inputs`. -4. **Variants**: replace the placeholders in §2 with declared inputs, one - commit, each variant bumping its patch version. - -## 8. Later - -- **Inputs inside lists** (`env`, `extraArgs`): an input path cannot address - one list item. A standalone chart fix (map-shaped `env` and `extraArgs`) - handles this. After it lands, the host NIC (13 variants) and the Mooncake - settings in `kimi-k3/vllm-tp8-b300`'s env become inputs. -- **Other site-specific values found in variants**: the RDMA device resource, - EBOF placement, HCA names, engine-cache hostPaths and Ascend runtime mounts. From c150e89fd6b107b81bf05c28fb28bb644bb066f5 Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 10:58:55 +0800 Subject: [PATCH 4/7] feat(schema): every input is required; a default is just its lowest answer "" and null are no longer read as "no default": a value values gives an input's path is its default and must be a valid answer, so a blank placeholder fails the input's type or pattern. Without a default, the site or the deploy answers it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- hack/lib/helm-validation.js | 4 ++-- hack/lib/helm-validation.test.js | 10 +++------- hack/lib/inputs.js | 9 +++------ hack/lib/inputs.test.js | 6 +++--- schema/version.schema.json | 2 +- web/src/types.ts | 2 +- 6 files changed, 13 insertions(+), 20 deletions(-) diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index c4678b1..420e351 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -5,7 +5,7 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const YAML = require("yaml"); const { modelNames, versionFiles, variantFiles, kindOf } = require("./catalog"); -const { hasDefault, valueAt } = require("./inputs"); +const { valueAt } = require("./inputs"); function affectsValidator(file) { return ( @@ -143,7 +143,7 @@ function catalogValues(variant) { } for (const input of variant.requires?.inputs ?? []) { const keys = input.path.split("."); - if (!hasDefault(valueAt(values, keys).value)) set(keys, input.example); + if (!("value" in valueAt(values, keys))) set(keys, input.example); } return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index 9d9afaa..5e26ec1 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -203,17 +203,13 @@ test("a declared input renders with its default, else its example", () => { servedName: "m", requires: { gpus: 8, - inputs: [ - input("storageKVOffload.mooncake.master"), - input("storageKVOffload.mooncake.te_meta_data_server"), - input("storageKVOffload.mooncake.device"), - ], + inputs: [input("storageKVOffload.mooncake.master"), input("storageKVOffload.mooncake.device")], }, - values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma", te_meta_data_server: "", device: "mlx5_3" } } }, + values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma", device: "mlx5_3" } } }, }; assert.deepEqual(catalogValues(variant).storageKVOffload, { enabled: true, - mooncake: { protocol: "rdma", master: "mooncake-master.example:50051", te_meta_data_server: "mooncake-master.example:50051", device: "mlx5_3" }, + mooncake: { protocol: "rdma", master: "mooncake-master.example:50051", device: "mlx5_3" }, }); assert.equal(variant.values.storageKVOffload.mooncake.master, undefined); }); diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index 5f59d52..42a070c 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,8 +1,7 @@ "use strict"; // requires.inputs, past what the schema can see. A path is declared once; a // value values gives it is its default, and like the example it must be a valid -// answer ("" or null is no default). swiss repeats these checks when it loads a -// variant (internal/catalog). +// answer. swiss repeats these checks when it loads a variant (internal/catalog). // Problems with one variant's inputs, each line prefixed with at. function inputProblems(variant, at = "") { @@ -38,7 +37,7 @@ function inputProblems(variant, at = "") { const found = valueAt(variant.values, input.path.split(".")); if (found.blocked) { lines.push(`${where}values.${found.blocked} is not a map, so it cannot hold this input`); - } else if (hasDefault(found.value)) { + } else if ("value" in found) { const problem = answerProblem(found.value); if (problem) lines.push(`${where}default ${problem}`); } @@ -46,8 +45,6 @@ function inputProblems(variant, at = "") { return lines; } -const hasDefault = (v) => v !== undefined && v !== null && v !== ""; - // What values holds at a path: { value }, or { blocked } naming a non-map on the way. function valueAt(values, keys) { let node = values; @@ -59,4 +56,4 @@ function valueAt(values, keys) { return { value: node }; } -module.exports = { inputProblems, hasDefault, valueAt }; +module.exports = { inputProblems, valueAt }; diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 992d41e..4222931 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -13,9 +13,7 @@ const variant = (inputs, values = {}) => ({ requires: { gpus: 8, inputs }, value test("a declared input with a valid example passes, with or without a default", () => { assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } })), []); - for (const def of ["mc:50051", "", null]) { - assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: { master: def } } })), []); - } + assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: { master: "mc:50051" } } })), []); assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); }); @@ -24,6 +22,8 @@ test("values' default for an input is an answer, and nothing in values is in its assert.deepEqual(one({ storageKVOffload: { mooncake: { master: "no-port" } } }), [ 'v: requires.inputs[0] storageKVOffload.mooncake.master: default "no-port" does not match the pattern', ]); + assert.match(one({ storageKVOffload: { mooncake: { master: "" } } })[0], /default "" does not match the pattern$/); + assert.match(one({ storageKVOffload: { mooncake: { master: null } } })[0], /default null is not a string$/); assert.match(one({ storageKVOffload: { mooncake: { master: { host: "h" } } } })[0], /default \{"host":"h"\} is not a string$/); assert.match(one({ a: { b: "8" } }, { path: "a.b", title: "t", type: "integer", example: 1 })[0], /default "8" is not an integer$/); assert.deepEqual(one({ storageKVOffload: { mooncake: "off" } }), [ diff --git a/schema/version.schema.json b/schema/version.schema.json index 60b2fe8..62dce58 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -147,7 +147,7 @@ "default": "nvidia" }, "inputs": { - "description": "Chart values a deploy is asked for, such as the address of a service the site runs. A value values gives the path is its default; the site profile's features.inputs answers over it, and the deploy's overrides over both. swiss refuses to compose a plan that leaves one with no default unanswered (\"\" is none). model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "description": "Chart values a deploy is asked for, such as the address of a service the site runs. Every input is required: swiss refuses to compose a plan that leaves one without a valid answer. The answers, each over the one before: a default values gives the path (a valid answer itself, never a blank placeholder), the site profile's features.inputs, the deploy's overrides. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", "type": "array", "minItems": 1, "items": { diff --git a/web/src/types.ts b/web/src/types.ts index de1a23d..372f08e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -11,7 +11,7 @@ export interface Requires { inputs?: Input[]; } -// A chart value the deploy is asked for; values may give it a default. +// A chart value every deploy must answer; values may give it a default. export interface Input { path: string; title: string; From c19a7bd1774c6234afcae1e64bf408f08d6bdb1e Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:08:49 +0800 Subject: [PATCH 5/7] feat(schema): an input's default lives on the input, not in values requires.inputs[].default is the answer swiss selects when the site profile gives none, and puts in the derived layer with the site's answers. It must be of the input's type and match its pattern. values again leaves every input's path alone: the answer lives in the derived layer, so a value there would be dead. validate:helm renders each input with its default, else its example. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- hack/lib/helm-validation.js | 14 +++++------- hack/lib/helm-validation.test.js | 8 ++++--- hack/lib/inputs.js | 37 ++++++++++++++++---------------- hack/lib/inputs.test.js | 37 +++++++++++++++++++------------- schema/version.schema.json | 8 +++++-- web/src/types.ts | 3 ++- 6 files changed, 58 insertions(+), 49 deletions(-) diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 420e351..2d97e15 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -5,13 +5,12 @@ const path = require("node:path"); const { spawnSync } = require("node:child_process"); const YAML = require("yaml"); const { modelNames, versionFiles, variantFiles, kindOf } = require("./catalog"); -const { valueAt } = require("./inputs"); function affectsValidator(file) { return ( file.startsWith("schema/crds/") || /^hack\/(validate-helm|install-helm-tools|build-crds)(\.integration)?\.(js|test\.js)$/.test(file) || - /^hack\/lib\/(helm-validation|crd-schema|download|catalog|inputs)(\.test)?\.js$/.test(file) || + /^hack\/lib\/(helm-validation|crd-schema|download|catalog)(\.test)?\.js$/.test(file) || ["package.json", "package-lock.json", ".github/workflows/lint.yml"].includes(file) ); } @@ -123,9 +122,9 @@ function parseVersions(file) { // variant spells first-class projected on top, so they win over the same keys // in values. image.digest is not projected. // -// A declared input without a default is set to its example, standing in for -// the answer a deploy gives (the site's features.inputs or the deploy's -// overrides), so the chart renders every path swiss fills. +// Each declared input is set to its default, else its example, standing in for +// the answer swiss puts in the derived layer at deploy, so the chart renders +// every path swiss fills. values never sets one; validate:schema refuses that. function catalogValues(variant) { const values = structuredClone(variant.values ?? {}); const set = (keys, value) => { @@ -141,10 +140,7 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } - for (const input of variant.requires?.inputs ?? []) { - const keys = input.path.split("."); - if (!("value" in valueAt(values, keys))) set(keys, input.example); - } + for (const input of variant.requires?.inputs ?? []) set(input.path.split("."), "default" in input ? input.default : input.example); return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index 5e26ec1..8057a25 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -198,14 +198,16 @@ test("variant values get swiss's projection of servedName, requires.gpus and the }); test("a declared input renders with its default, else its example", () => { - const input = (path) => ({ path, title: "t", example: "mooncake-master.example:50051" }); const variant = { servedName: "m", requires: { gpus: 8, - inputs: [input("storageKVOffload.mooncake.master"), input("storageKVOffload.mooncake.device")], + inputs: [ + { path: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, + { path: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, + ], }, - values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma", device: "mlx5_3" } } }, + values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, }; assert.deepEqual(catalogValues(variant).storageKVOffload, { enabled: true, diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index 42a070c..4c9904a 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,7 +1,8 @@ "use strict"; -// requires.inputs, past what the schema can see. A path is declared once; a -// value values gives it is its default, and like the example it must be a valid -// answer. swiss repeats these checks when it loads a variant (internal/catalog). +// requires.inputs, past what the schema can see. A path is declared once, and +// values leaves it alone: swiss puts the answer, the site's or else the input's +// default, in the derived layer. The example and the default are valid answers. +// swiss repeats these checks when it loads a variant (internal/catalog). // Problems with one variant's inputs, each line prefixed with at. function inputProblems(variant, at = "") { @@ -13,6 +14,8 @@ function inputProblems(variant, at = "") { const where = `${at}requires.inputs[${i}] ${input.path}: `; if (declared.has(input.path)) lines.push(`${where}declared twice`); declared.add(input.path); + const set = valueSetAt(variant.values, input.path.split(".")); + if (set) lines.push(`${where}values.${set} sets it; give the value as the input's default`); const type = input.type ?? "string"; let pattern; if (input.pattern !== undefined && type !== "string") { @@ -30,30 +33,26 @@ function inputProblems(variant, at = "") { if (pattern && !pattern.test(v)) return `${JSON.stringify(v)} does not match the pattern`; return ""; }; - if ("example" in input) { - const problem = answerProblem(input.example); - if (problem) lines.push(`${where}example ${problem}`); - } - const found = valueAt(variant.values, input.path.split(".")); - if (found.blocked) { - lines.push(`${where}values.${found.blocked} is not a map, so it cannot hold this input`); - } else if ("value" in found) { - const problem = answerProblem(found.value); - if (problem) lines.push(`${where}default ${problem}`); + for (const field of ["example", "default"]) { + if (!(field in input)) continue; + const problem = answerProblem(input[field]); + if (problem) lines.push(`${where}${field} ${problem}`); } } return lines; } -// What values holds at a path: { value }, or { blocked } naming a non-map on the way. -function valueAt(values, keys) { +// The prefix of keys at which values already says something about a path: +// the value itself, a value under it, or a non-map on the way to it. Empty +// when values leaves the path alone. +function valueSetAt(values, keys) { let node = values; for (const [i, key] of keys.entries()) { - if (node === null || typeof node !== "object" || Array.isArray(node)) return i > 0 ? { blocked: keys.slice(0, i).join(".") } : {}; - if (!Object.hasOwn(node, key)) return {}; + if (node === null || typeof node !== "object" || Array.isArray(node)) return i > 0 ? keys.slice(0, i).join(".") : ""; + if (!Object.hasOwn(node, key)) return ""; node = node[key]; } - return { value: node }; + return keys.join("."); } -module.exports = { inputProblems, valueAt }; +module.exports = { inputProblems }; diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 4222931..9cb3ab4 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -11,24 +11,31 @@ const master = { }; const variant = (inputs, values = {}) => ({ requires: { gpus: 8, inputs }, values }); -test("a declared input with a valid example passes, with or without a default", () => { - assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } })), []); - assert.deepEqual(inputProblems(variant([master], { storageKVOffload: { mooncake: { master: "mc:50051" } } })), []); +test("a declared input that values leaves alone, with a valid example and default, passes", () => { + const values = { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }; + assert.deepEqual(inputProblems(variant([master], values)), []); + assert.deepEqual(inputProblems(variant([{ ...master, default: "mc:50051" }], values)), []); assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); }); -test("values' default for an input is an answer, and nothing in values is in its way", () => { - const one = (values, input = master) => inputProblems(variant([input], values), "v: "); - assert.deepEqual(one({ storageKVOffload: { mooncake: { master: "no-port" } } }), [ - 'v: requires.inputs[0] storageKVOffload.mooncake.master: default "no-port" does not match the pattern', - ]); - assert.match(one({ storageKVOffload: { mooncake: { master: "" } } })[0], /default "" does not match the pattern$/); - assert.match(one({ storageKVOffload: { mooncake: { master: null } } })[0], /default null is not a string$/); - assert.match(one({ storageKVOffload: { mooncake: { master: { host: "h" } } } })[0], /default \{"host":"h"\} is not a string$/); - assert.match(one({ a: { b: "8" } }, { path: "a.b", title: "t", type: "integer", example: 1 })[0], /default "8" is not an integer$/); - assert.deepEqual(one({ storageKVOffload: { mooncake: "off" } }), [ - "v: requires.inputs[0] storageKVOffload.mooncake.master: values.storageKVOffload.mooncake is not a map, so it cannot hold this input", - ]); +test("values may not set an input: at its path, under it, or with a non-map on the way", () => { + for (const [values, at] of [ + [{ storageKVOffload: { mooncake: { master: "mc:50051" } } }, "storageKVOffload.mooncake.master"], + [{ storageKVOffload: { mooncake: { master: { host: "h" } } } }, "storageKVOffload.mooncake.master"], + [{ storageKVOffload: { mooncake: "off" } }, "storageKVOffload.mooncake"], + ]) { + assert.deepEqual(inputProblems(variant([master], values), "v: "), [ + `v: requires.inputs[0] storageKVOffload.mooncake.master: values.${at} sets it; give the value as the input's default`, + ]); + } +}); + +test("the default is an answer: of the input's type, matching its pattern", () => { + const one = (input) => inputProblems(variant([{ path: "a.b", title: "t", ...input }])); + assert.match(one({ example: "h:1", default: "no-port", pattern: master.pattern })[0], /default "no-port" does not match the pattern$/); + assert.match(one({ example: "h:1", default: "", pattern: master.pattern })[0], /default "" does not match the pattern$/); + assert.match(one({ example: 1, default: "8", type: "integer" })[0], /default "8" is not an integer$/); + assert.deepEqual(one({ example: false, default: true, type: "boolean" }), []); }); test("a path is declared once", () => { diff --git a/schema/version.schema.json b/schema/version.schema.json index 62dce58..6dd58c5 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -147,7 +147,7 @@ "default": "nvidia" }, "inputs": { - "description": "Chart values a deploy is asked for, such as the address of a service the site runs. Every input is required: swiss refuses to compose a plan that leaves one without a valid answer. The answers, each over the one before: a default values gives the path (a valid answer itself, never a blank placeholder), the site profile's features.inputs, the deploy's overrides. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "description": "Chart values every deploy must answer, such as the address of a service the site runs. swiss puts the answer in the derived layer -- the site profile's features.inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's path. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", "type": "array", "minItems": 1, "items": { @@ -182,7 +182,11 @@ "minLength": 1 }, "example": { - "description": "A value of the right shape, never a real one: the deploy form's placeholder, and what CI renders the chart with. Never deployed. Must be of the input's type and match its pattern.", + "description": "A value of the right shape, never a real one: the deploy form's placeholder, and what CI renders the chart with when there is no default. Never deployed. Must be of the input's type and match its pattern.", + "type": ["string", "integer", "boolean"] + }, + "default": { + "description": "The answer when the site profile gives none, such as the value of the cluster the variant was tuned on. Must be of the input's type and match its pattern.", "type": ["string", "integer", "boolean"] } } diff --git a/web/src/types.ts b/web/src/types.ts index 372f08e..1c387e1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -11,7 +11,7 @@ export interface Requires { inputs?: Input[]; } -// A chart value every deploy must answer; values may give it a default. +// A chart value every deploy must answer; swiss falls back to default. export interface Input { path: string; title: string; @@ -19,6 +19,7 @@ export interface Input { type?: "string" | "integer" | "boolean"; pattern?: string; example: string | number | boolean; + default?: string | number | boolean; } export interface Chart { From f8dfc03435abf1a47da8ff69f3135adec6f2d03a Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:27:51 +0800 Subject: [PATCH 6/7] feat(schema): inputs beside requires, not inside it A require is an abstraction swiss interprets and checks against the cluster; an input names one chart path and swiss only relays its answer. So inputs moves to the variant's top level, in v1 and v2 alike. The v2 index no longer carries them, since nothing lists or picks a variant by them, and the catalog web's index types drop Input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011bWVCSybBZ1QmsL9gjrqxW --- hack/lib/helm-validation.js | 2 +- hack/lib/helm-validation.test.js | 12 ++--- hack/lib/inputs.js | 6 +-- hack/lib/inputs.test.js | 6 +-- schema/variant.schema.json | 3 ++ schema/version.schema.json | 92 ++++++++++++++++---------------- web/src/types.ts | 12 ----- 7 files changed, 61 insertions(+), 72 deletions(-) diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 2d97e15..5525f61 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -140,7 +140,7 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } - for (const input of variant.requires?.inputs ?? []) set(input.path.split("."), "default" in input ? input.default : input.example); + for (const input of variant.inputs ?? []) set(input.path.split("."), "default" in input ? input.default : input.example); return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index 8057a25..9c468c2 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -200,13 +200,11 @@ test("variant values get swiss's projection of servedName, requires.gpus and the test("a declared input renders with its default, else its example", () => { const variant = { servedName: "m", - requires: { - gpus: 8, - inputs: [ - { path: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, - { path: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, - ], - }, + requires: { gpus: 8 }, + inputs: [ + { path: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, + { path: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, + ], values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, }; assert.deepEqual(catalogValues(variant).storageKVOffload, { diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index 4c9904a..ba25073 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,17 +1,17 @@ "use strict"; -// requires.inputs, past what the schema can see. A path is declared once, and +// A variant's inputs, past what the schema can see. A path is declared once, and // values leaves it alone: swiss puts the answer, the site's or else the input's // default, in the derived layer. The example and the default are valid answers. // swiss repeats these checks when it loads a variant (internal/catalog). // Problems with one variant's inputs, each line prefixed with at. function inputProblems(variant, at = "") { - const inputs = Array.isArray(variant?.requires?.inputs) ? variant.requires.inputs : []; + const inputs = Array.isArray(variant?.inputs) ? variant.inputs : []; const lines = []; const declared = new Set(); for (const [i, input] of inputs.entries()) { if (typeof input?.path !== "string") continue; // the schema reports it - const where = `${at}requires.inputs[${i}] ${input.path}: `; + const where = `${at}inputs[${i}] ${input.path}: `; if (declared.has(input.path)) lines.push(`${where}declared twice`); declared.add(input.path); const set = valueSetAt(variant.values, input.path.split(".")); diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 9cb3ab4..9015742 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -9,7 +9,7 @@ const master = { pattern: "[^\\s:/]+:[0-9]+", example: "mooncake-master.example:50051", }; -const variant = (inputs, values = {}) => ({ requires: { gpus: 8, inputs }, values }); +const variant = (inputs, values = {}) => ({ requires: { gpus: 8 }, inputs, values }); test("a declared input that values leaves alone, with a valid example and default, passes", () => { const values = { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }; @@ -25,7 +25,7 @@ test("values may not set an input: at its path, under it, or with a non-map on t [{ storageKVOffload: { mooncake: "off" } }, "storageKVOffload.mooncake"], ]) { assert.deepEqual(inputProblems(variant([master], values), "v: "), [ - `v: requires.inputs[0] storageKVOffload.mooncake.master: values.${at} sets it; give the value as the input's default`, + `v: inputs[0] storageKVOffload.mooncake.master: values.${at} sets it; give the value as the input's default`, ]); } }); @@ -39,7 +39,7 @@ test("the default is an answer: of the input's type, matching its pattern", () = }); test("a path is declared once", () => { - assert.deepEqual(inputProblems(variant([master, master])), ["requires.inputs[1] storageKVOffload.mooncake.master: declared twice"]); + assert.deepEqual(inputProblems(variant([master, master])), ["inputs[1] storageKVOffload.mooncake.master: declared twice"]); }); test("the example is an answer: of the input's type, matching its pattern", () => { diff --git a/schema/variant.schema.json b/schema/variant.schema.json index 44d9f53..dd9d5f7 100644 --- a/schema/variant.schema.json +++ b/schema/variant.schema.json @@ -48,6 +48,9 @@ "requires": { "$ref": "version.schema.json#/$defs/variant/properties/requires" }, + "inputs": { + "$ref": "version.schema.json#/$defs/variant/properties/inputs" + }, "values": { "$ref": "version.schema.json#/$defs/variant/properties/values" }, diff --git a/schema/version.schema.json b/schema/version.schema.json index 6dd58c5..7ff3df8 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -145,52 +145,6 @@ "description": "Accelerator brand this variant is built for. Decides the extended resource the pod requests and the node label its product is published under -- nvidia.com/gpu and nvidia.com/gpu.product, huawei.com/Ascend910 and its own label, and so on. Singular on purpose: a CANN build of an engine is a different image with different flags than a CUDA build, so a model that runs on both publishes two variants rather than one variant claiming both.", "enum": ["nvidia", "ascend", "cambricon", "hygon", "amd"], "default": "nvidia" - }, - "inputs": { - "description": "Chart values every deploy must answer, such as the address of a service the site runs. swiss puts the answer in the derived layer -- the site profile's features.inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's path. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["path", "title", "example"], - "properties": { - "path": { - "description": "The chart value, as a dotted path: storageKVOffload.mooncake.master. Unique within the variant.", - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_-]*(\\.[A-Za-z_][A-Za-z0-9_-]*)*$", - "not": { "enum": ["model.localPath"] } - }, - "title": { - "description": "What the deploy form labels the field with.", - "type": "string", - "minLength": 1 - }, - "description": { - "description": "What the value is, for the deploy form.", - "type": "string", - "minLength": 1 - }, - "type": { - "description": "What an answer is.", - "enum": ["string", "integer", "boolean"], - "default": "string" - }, - "pattern": { - "description": "A regular expression a string answer matches, whole. Keep to the syntax Go and JavaScript read alike: swiss checks it, and so does the deploy form.", - "type": "string", - "minLength": 1 - }, - "example": { - "description": "A value of the right shape, never a real one: the deploy form's placeholder, and what CI renders the chart with when there is no default. Never deployed. Must be of the input's type and match its pattern.", - "type": ["string", "integer", "boolean"] - }, - "default": { - "description": "The answer when the site profile gives none, such as the value of the cluster the variant was tuned on. Must be of the input's type and match its pattern.", - "type": ["string", "integer", "boolean"] - } - } - } } }, "allOf": [ @@ -214,6 +168,52 @@ } ] }, + "inputs": { + "description": "Chart values every deploy must answer, such as the address of a service the site runs. Unlike requires, which swiss interprets and checks against the cluster, an input names one chart path, and swiss only relays its answer: swiss puts the answer in the derived layer -- the site profile's inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's path. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "title", "example"], + "properties": { + "path": { + "description": "The chart value, as a dotted path: storageKVOffload.mooncake.master. Unique within the variant.", + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_-]*(\\.[A-Za-z_][A-Za-z0-9_-]*)*$", + "not": { "enum": ["model.localPath"] } + }, + "title": { + "description": "What the deploy form labels the field with.", + "type": "string", + "minLength": 1 + }, + "description": { + "description": "What the value is, for the deploy form.", + "type": "string", + "minLength": 1 + }, + "type": { + "description": "What an answer is.", + "enum": ["string", "integer", "boolean"], + "default": "string" + }, + "pattern": { + "description": "A regular expression a string answer matches, whole. Keep to the syntax Go and JavaScript read alike: swiss checks it, and so does the deploy form.", + "type": "string", + "minLength": 1 + }, + "example": { + "description": "A value of the right shape, never a real one: the deploy form's placeholder, and what CI renders the chart with when there is no default. Never deployed. Must be of the input's type and match its pattern.", + "type": ["string", "integer", "boolean"] + }, + "default": { + "description": "The answer when the site profile gives none, such as the value of the cluster the variant was tuned on. Must be of the input's type and match its pattern.", + "type": ["string", "integer", "boolean"] + } + } + } + }, "values": { "description": "Chart values this variant sets. Any object", "type": "object" diff --git a/web/src/types.ts b/web/src/types.ts index 1c387e1..bcb9b2e 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -8,18 +8,6 @@ export interface Requires { vendor?: string; gpuProduct?: string[]; topology?: string; - inputs?: Input[]; -} - -// A chart value every deploy must answer; swiss falls back to default. -export interface Input { - path: string; - title: string; - description?: string; - type?: "string" | "integer" | "boolean"; - pattern?: string; - example: string | number | boolean; - default?: string | number | boolean; } export interface Chart { From 845c3d695a3665479738d4496b15890ba5efd8af Mon Sep 17 00:00:00 2001 From: aceforeverd Date: Tue, 10 Nov 2026 11:45:43 +0800 Subject: [PATCH 7/7] feat(schema): name an input's chart value inputs[].key It is the dotted key helm --set takes, so key rather than path. --- hack/lib/helm-validation.js | 2 +- hack/lib/helm-validation.test.js | 4 ++-- hack/lib/inputs.js | 18 +++++++++--------- hack/lib/inputs.test.js | 12 ++++++------ schema/version.schema.json | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js index 5525f61..83c25e6 100644 --- a/hack/lib/helm-validation.js +++ b/hack/lib/helm-validation.js @@ -140,7 +140,7 @@ function catalogValues(variant) { set(["image", "repository"], variant.image.repository); set(["image", "tag"], String(variant.image.tag ?? "")); } - for (const input of variant.inputs ?? []) set(input.path.split("."), "default" in input ? input.default : input.example); + for (const input of variant.inputs ?? []) set(input.key.split("."), "default" in input ? input.default : input.example); return values; } diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js index 9c468c2..ca08bd3 100644 --- a/hack/lib/helm-validation.test.js +++ b/hack/lib/helm-validation.test.js @@ -202,8 +202,8 @@ test("a declared input renders with its default, else its example", () => { servedName: "m", requires: { gpus: 8 }, inputs: [ - { path: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, - { path: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, + { key: "storageKVOffload.mooncake.master", title: "t", example: "mooncake-master.example:50051" }, + { key: "storageKVOffload.mooncake.device", title: "t", example: "mlx5_0", default: "mlx5_3" }, ], values: { storageKVOffload: { enabled: true, mooncake: { protocol: "rdma" } } }, }; diff --git a/hack/lib/inputs.js b/hack/lib/inputs.js index ba25073..b3bc384 100644 --- a/hack/lib/inputs.js +++ b/hack/lib/inputs.js @@ -1,5 +1,5 @@ "use strict"; -// A variant's inputs, past what the schema can see. A path is declared once, and +// A variant's inputs, past what the schema can see. A key is declared once, and // values leaves it alone: swiss puts the answer, the site's or else the input's // default, in the derived layer. The example and the default are valid answers. // swiss repeats these checks when it loads a variant (internal/catalog). @@ -10,11 +10,11 @@ function inputProblems(variant, at = "") { const lines = []; const declared = new Set(); for (const [i, input] of inputs.entries()) { - if (typeof input?.path !== "string") continue; // the schema reports it - const where = `${at}inputs[${i}] ${input.path}: `; - if (declared.has(input.path)) lines.push(`${where}declared twice`); - declared.add(input.path); - const set = valueSetAt(variant.values, input.path.split(".")); + if (typeof input?.key !== "string") continue; // the schema reports it + const where = `${at}inputs[${i}] ${input.key}: `; + if (declared.has(input.key)) lines.push(`${where}declared twice`); + declared.add(input.key); + const set = valueSetAt(variant.values, input.key.split(".")); if (set) lines.push(`${where}values.${set} sets it; give the value as the input's default`); const type = input.type ?? "string"; let pattern; @@ -42,9 +42,9 @@ function inputProblems(variant, at = "") { return lines; } -// The prefix of keys at which values already says something about a path: -// the value itself, a value under it, or a non-map on the way to it. Empty -// when values leaves the path alone. +// The prefix of keys at which values already says something about a dotted +// key: the value itself, a value under it, or a non-map on the way to it. +// Empty when values leaves the key alone. function valueSetAt(values, keys) { let node = values; for (const [i, key] of keys.entries()) { diff --git a/hack/lib/inputs.test.js b/hack/lib/inputs.test.js index 9015742..752d1a0 100644 --- a/hack/lib/inputs.test.js +++ b/hack/lib/inputs.test.js @@ -4,7 +4,7 @@ const assert = require("node:assert/strict"); const { inputProblems } = require("./inputs"); const master = { - path: "storageKVOffload.mooncake.master", + key: "storageKVOffload.mooncake.master", title: "Mooncake master", pattern: "[^\\s:/]+:[0-9]+", example: "mooncake-master.example:50051", @@ -18,7 +18,7 @@ test("a declared input that values leaves alone, with a valid example and defaul assert.deepEqual(inputProblems({ requires: { gpus: 1 } }), []); }); -test("values may not set an input: at its path, under it, or with a non-map on the way", () => { +test("values may not set an input: at its key, under it, or with a non-map on the way", () => { for (const [values, at] of [ [{ storageKVOffload: { mooncake: { master: "mc:50051" } } }, "storageKVOffload.mooncake.master"], [{ storageKVOffload: { mooncake: { master: { host: "h" } } } }, "storageKVOffload.mooncake.master"], @@ -31,19 +31,19 @@ test("values may not set an input: at its path, under it, or with a non-map on t }); test("the default is an answer: of the input's type, matching its pattern", () => { - const one = (input) => inputProblems(variant([{ path: "a.b", title: "t", ...input }])); + const one = (input) => inputProblems(variant([{ key: "a.b", title: "t", ...input }])); assert.match(one({ example: "h:1", default: "no-port", pattern: master.pattern })[0], /default "no-port" does not match the pattern$/); assert.match(one({ example: "h:1", default: "", pattern: master.pattern })[0], /default "" does not match the pattern$/); assert.match(one({ example: 1, default: "8", type: "integer" })[0], /default "8" is not an integer$/); assert.deepEqual(one({ example: false, default: true, type: "boolean" }), []); }); -test("a path is declared once", () => { +test("a key is declared once", () => { assert.deepEqual(inputProblems(variant([master, master])), ["inputs[1] storageKVOffload.mooncake.master: declared twice"]); }); test("the example is an answer: of the input's type, matching its pattern", () => { - const one = (input) => inputProblems(variant([{ path: "a.b", title: "t", ...input }])); + const one = (input) => inputProblems(variant([{ key: "a.b", title: "t", ...input }])); assert.deepEqual(one({ example: "mooncake-master:50051", pattern: master.pattern }), []); assert.match(one({ example: "no-port", pattern: master.pattern })[0], /example "no-port" does not match the pattern$/); assert.match(one({ example: "8", type: "integer" })[0], /example "8" is not an integer$/); @@ -56,7 +56,7 @@ test("the example is an answer: of the input's type, matching its pattern", () = test("a pattern compiles, and only a string input has one", () => { assert.match(inputProblems(variant([{ ...master, pattern: "(" }]))[0], /pattern does not compile/); assert.match( - inputProblems(variant([{ path: "a.b", title: "t", type: "integer", pattern: "[0-9]+", example: 1 }]))[0], + inputProblems(variant([{ key: "a.b", title: "t", type: "integer", pattern: "[0-9]+", example: 1 }]))[0], /pattern is only for a string input, and this one is integer$/, ); }); diff --git a/schema/version.schema.json b/schema/version.schema.json index 7ff3df8..92980b2 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -169,16 +169,16 @@ ] }, "inputs": { - "description": "Chart values every deploy must answer, such as the address of a service the site runs. Unlike requires, which swiss interprets and checks against the cluster, an input names one chart path, and swiss only relays its answer: swiss puts the answer in the derived layer -- the site profile's inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's path. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", + "description": "Chart values every deploy must answer, such as the address of a service the site runs. Unlike requires, which swiss interprets and checks against the cluster, an input names one chart value by its key, and swiss only relays its answer: swiss puts the answer in the derived layer -- the site profile's inputs, else the input's default -- and the deploy's overrides go over it. swiss refuses to compose a plan that leaves one without a valid answer. values never sets an input's key. model.localPath is never one: swiss answers it from the site's model.pathTemplate.", "type": "array", "minItems": 1, "items": { "type": "object", "additionalProperties": false, - "required": ["path", "title", "example"], + "required": ["key", "title", "example"], "properties": { - "path": { - "description": "The chart value, as a dotted path: storageKVOffload.mooncake.master. Unique within the variant.", + "key": { + "description": "The chart value's dotted key, as helm --set takes it: storageKVOffload.mooncake.master. Unique within the variant.", "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]*(\\.[A-Za-z_][A-Za-z0-9_-]*)*$", "not": { "enum": ["model.localPath"] }