diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dfe4bde..2655b43 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,3 +26,42 @@ jobs: - name: Test the catalog library run: npm run test:lib + + validate-helm: + name: validate-helm + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + # Compare the PR head, not GitHub's synthetic merge commit. + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + + - name: Install validator dependencies + run: npm ci + + - name: Test Helm validator + run: npm run test:helm + + - name: Install pinned Helm and kubeconform + run: npm run helm:install + + - name: Validate changed model variants against latest stable charts + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + run: npm run validate:helm -- --base "$BASE_REF" --chart-mode latest + + - name: Save Helm validation logs and manifests + if: always() + uses: actions/upload-artifact@v4 + with: + name: helm-validation + path: artifacts/helm-validation/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 66af114..025caf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # MacOS *.DS_Store +.cache/helm-validation/ +artifacts/helm-validation/ # Created by https://www.toptal.com/developers/gitignore/api/node # Edit at https://www.toptal.com/developers/gitignore?templates=node diff --git a/README.md b/README.md index 1a33159..96654f1 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ cp -r models/glm5.1 models/my-model # or models/kimi-k2.5 for a multi-node mode 3. Check it, and preview the catalog page: ```sh - npm run validate:schema # the same check CI runs + npm run validate:schema # catalog structure and tuning references + npm run helm:install # pinned Helm and kubeconform (first time) + npm run validate:helm -- models/my-model/my-model-1.0.0.yaml npm run build:site # then open site/index.html ``` @@ -114,6 +116,13 @@ hack/serve.sh serve it over HTTP, for local development A published catalog is a static site, and `./hack/serve.sh` is the smallest thing that behaves like one: it rebuilds the index, then serves the tree. +PRs also run the independent `validate-helm` check on every variant in changed +version YAMLs, using the latest stable released chart for each engine, Helm's +values schema and strict Kubernetes/CRD schemas. See [Helm validation](schema/crds/README.md) for +local commands, schema updates, artifacts, and validation limits. +Deployment still follows the YAML chart declaration. Use `--chart-mode declared` +with `npm run validate:helm` to reproduce that release locally. + ## Versions are immutable A model publishes versions, like a package: `models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.2.0.yaml`, @@ -141,10 +150,10 @@ publishing a new one. | `chart.version` | means | | --- | --- | -| `0.7.1` | exactly 0.7.1 | -| `">=0.7.1"` | 0.7.1 or newer (quoted: a bare `>` starts a YAML block scalar) | -| `"^0.7.1"` | 0.7.x from 0.7.1; in 0.x, a minor bump is the breaking one | -| `">=0.7.1 <0.9.0"` | both | +| `0.8.0` | exactly 0.8.0 | +| `">=0.8.0"` | 0.8.0 or newer (quoted: a bare `>` starts a YAML block scalar) | +| `"^0.8.0"` | 0.8.x from 0.8.0; in 0.x, a minor bump is the breaking one | +| `">=0.8.0 <0.9.0"` | both | A deploy still pins: swissd records the one version the range resolved to (the newest in range, or one the operator picks) and an upgrade keeps it until asked @@ -317,4 +326,3 @@ exist until the site is deployed. swissd refuses an index with a field it does not know. A field added to `index.json` -- `site` and `tuning` among them -- has to be understood by every swissd reading this catalog before it merges: the next deploy publishes it. - diff --git a/hack/build-crds.js b/hack/build-crds.js new file mode 100644 index 0000000..3ddb232 --- /dev/null +++ b/hack/build-crds.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +"use strict"; + +// Build the kubeconform CRD schemas the way npm install builds node_modules. +// crds.json pins each CRD to a commit; crds-lock.json records the sha256 +// of what each pin holds. A lock in step with crds.json is honored, and +// different bytes are refused; a missing or stale lock is written afresh. +// --locked (CI) refuses to rewrite the lock instead, like npm ci. +// node hack/build-crds.js [--locked] [DIR] +const fs = require("node:fs"); +const path = require("node:path"); +const {root} = require("./lib/catalog"); +const {buildSchemas} = require("./lib/crd-schema"); +const config = require("../schema/crds/config.json"); +const {sources} = require("../schema/crds/crds.json"); + +const lockFile = path.join(root, "schema/crds/crds-lock.json"); +const objectMetaUrl = `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/${config.kubernetesSchemaCommit}/` + + `v${config.kubernetesVersion}-standalone-strict/_definitions.json`; + +function pins(list) { + return JSON.stringify(list.map(({repository, commit, path: file, kind, group}) => [repository, commit, file, kind, group])); +} + +async function main(args) { + const locked = args.includes("--locked"); + const rest = args.filter((arg) => arg !== "--locked"); + if (rest.length > 1 || rest.some((arg) => arg.startsWith("-"))) throw new Error("usage: build-crds.js [--locked] [DIR]"); + const text = fs.existsSync(lockFile) ? fs.readFileSync(lockFile, "utf8") : null; + const lock = text && JSON.parse(text); + const current = lock?.objectMeta?.url === objectMetaUrl && pins(lock.sources) === pins(sources); + const stale = `${path.relative(root, lockFile)} is out of date; run npm run helm:build-crds and commit it`; + if (!current && locked) throw new Error(stale); + const built = current ? await buildSchemas(lock.sources, lock.objectMeta) : await buildSchemas(sources, {url: objectMetaUrl}); + const next = JSON.stringify({objectMeta: built.objectMeta, sources: built.records}, null, 2) + "\n"; + if (next !== text) { + if (locked) throw new Error(stale); + fs.writeFileSync(lockFile, next); + console.log(`Updated ${path.relative(root, lockFile)}`); + } + if (!rest.length) return; + const output = path.resolve(rest[0]); + for (const [file, body] of built.files) { + fs.mkdirSync(path.dirname(path.join(output, file)), {recursive: true}); + fs.writeFileSync(path.join(output, file), body); + } + console.log(`Built ${built.files.size} CRD schemas in ${output}`); +} + +main(process.argv.slice(2)).catch((err) => {console.error(err.message); process.exitCode = 1;}); diff --git a/hack/install-helm-tools.js b/hack/install-helm-tools.js new file mode 100644 index 0000000..189df7c --- /dev/null +++ b/hack/install-helm-tools.js @@ -0,0 +1,41 @@ +#!/usr/bin/env node +"use strict"; + +// Install the pinned helm and kubeconform into .cache/helm-validation/bin, +// refusing an archive whose sha256 differs from config.json. +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const {execFileSync} = require("node:child_process"); +const {root} = require("./lib/catalog"); +const {download} = require("./lib/download"); +const config = require("../schema/crds/config.json"); + +// Each tool's release archive, and the binary's path inside it. +const tools = { + helm: (version, platform) => [`https://get.helm.sh/helm-v${version}-${platform}.tar.gz`, `${platform}/helm`], + kubeconform: (version, platform) => + [`https://github.com/yannh/kubeconform/releases/download/v${version}/kubeconform-${platform}.tar.gz`, "kubeconform"], +}; + +async function main() { + const platform = `${process.platform}-${{x64: "amd64", arm64: "arm64"}[process.arch]}`; + const sums = config.toolChecksums[platform]; + if (!sums) throw new Error(`unsupported platform: ${platform}`); + const output = path.join(root, ".cache/helm-validation/bin"); + fs.mkdirSync(output, {recursive: true}); + for (const [name, release] of Object.entries(tools)) { + const version = config[`${name}Version`]; + const [url, member] = release(version, platform); + const archive = await download(url); + if (crypto.createHash("sha256").update(archive).digest("hex") !== sums[name]) throw new Error(`${url}: checksum mismatch`); + const binary = execFileSync("tar", ["-xzOf", "-", member], {input: archive, maxBuffer: 512 * 1024 * 1024}); + // Rename over the old binary: writing into one that is running fails. + const target = path.join(output, name); + fs.writeFileSync(`${target}.new`, binary, {mode: 0o755}); + fs.renameSync(`${target}.new`, target); + console.log(`Installed ${name} ${version} (${platform}, sha256:${sums[name]})`); + } +} + +main().catch((err) => {console.error(err.message); process.exitCode = 1;}); diff --git a/hack/lib/catalog.js b/hack/lib/catalog.js index b1efe98..cc78c7e 100644 --- a/hack/lib/catalog.js +++ b/hack/lib/catalog.js @@ -15,18 +15,18 @@ function readYaml(p) { } // Model directory names, sorted. -function modelNames() { +function modelNames(dir = modelsDir) { return fs - .readdirSync(modelsDir) - .filter((name) => fs.statSync(path.join(modelsDir, name)).isDirectory()) + .readdirSync(dir) + .filter((name) => fs.statSync(path.join(dir, name)).isDirectory()) .sort(); } // A model's version files (-.yaml), as repo-relative paths. // metadata.yaml has no "-", which is what tells the two apart. -function versionFiles(name) { +function versionFiles(name, dir = modelsDir) { return fs - .readdirSync(path.join(modelsDir, name)) + .readdirSync(path.join(dir, name)) .filter((f) => f.endsWith(".yaml") && f.includes("-")) .sort() .map((f) => `models/${name}/${f}`); diff --git a/hack/lib/crd-schema.js b/hack/lib/crd-schema.js new file mode 100644 index 0000000..5d3bfd8 --- /dev/null +++ b/hack/lib/crd-schema.js @@ -0,0 +1,127 @@ +"use strict"; + +const crypto = require("node:crypto"); +const YAML = require("yaml"); +const {download} = require("./download"); + +function assertCrdSource(source, crd) { + if (source.repository.startsWith("modelsphere/") && !source.group?.endsWith(".modelsphere.dev")) { + throw new Error(`${source.repository}: Modelsphere CRDs must use a .modelsphere.dev API group`); + } + if (crd?.kind !== "CustomResourceDefinition" || crd.spec?.names?.kind !== source.kind || crd.spec?.group !== source.group) { + throw new Error(`expected CRD ${source.group}/${source.kind}, got ${crd?.spec?.group}/${crd?.spec?.names?.kind}`); + } +} + +function convertSchema(input) { + if (typeof input === "boolean") return input; + const schema = structuredClone(input); + for (const key of ["properties", "patternProperties", "definitions", "$defs"]) { + if (schema[key]) { + schema[key] = Object.fromEntries(Object.entries(schema[key]).map(([k, v]) => [k, convertSchema(v)])); + } + } + for (const key of ["items", "additionalProperties", "not", "contains", "if", "then", "else"]) { + if (schema[key] && typeof schema[key] === "object") { + schema[key] = Array.isArray(schema[key]) ? schema[key].map(convertSchema) : convertSchema(schema[key]); + } + } + for (const key of ["allOf", "anyOf", "oneOf"]) { + if (schema[key]) schema[key] = schema[key].map(convertSchema); + } + // Close defined objects, including objects inside arrays. Explicit map schemas + // and Kubernetes' preserve-unknown-fields escape hatch must remain open. + if (schema.properties && schema.additionalProperties === undefined) { + schema.additionalProperties = schema["x-kubernetes-preserve-unknown-fields"] === true; + } + if (schema["x-kubernetes-int-or-string"] || schema.format === "int-or-string") { + schema.type = ["integer", "string"]; + delete schema.format; + } + if (schema.nullable === true) { + delete schema.nullable; + return {anyOf: [schema, {type: "null"}]}; + } + delete schema.nullable; + return schema; +} + +function objectMetaSchema(definitions) { + const name = "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"; + const used = {}; + function visit(value) { + if (!value || typeof value !== "object") return; + if (value.$ref) { + const prefix = "#/definitions/"; + if (!value.$ref.startsWith(prefix)) throw new Error(`unexpected metadata reference ${value.$ref}`); + const key = value.$ref.slice(prefix.length); + if (!Object.hasOwn(used, key)) { + if (!definitions[key]) throw new Error(`missing metadata definition ${key}`); + used[key] = definitions[key]; + visit(used[key]); + } + } + for (const child of Object.values(value)) visit(child); + } + const schema = {$ref: `#/definitions/${name}`}; + visit(schema); + return {schema, definitions: used}; +} + +function crdSchemas(crd, metadata = {schema: {type: "object"}, definitions: {}}) { + if (crd?.kind !== "CustomResourceDefinition") throw new Error("expected a CustomResourceDefinition"); + const {group, names, versions} = crd.spec; + return versions.filter((v) => v.served).map((v) => { + if (!v.schema?.openAPIV3Schema) throw new Error(`${group}/${v.name}/${names.kind}: missing OpenAPI schema`); + const schema = convertSchema(v.schema.openAPIV3Schema); + schema.$schema = "http://json-schema.org/draft-07/schema#"; + schema.properties ??= {}; + schema.properties.apiVersion = {type: "string", enum: [`${group}/${v.name}`]}; + schema.properties.kind = {type: "string", enum: [names.kind]}; + // The API server supplies ObjectMeta even when the CRD omits metadata or + // leaves it as type: object. Use the same pinned Kubernetes schema as native resources. + schema.properties.metadata = structuredClone(metadata.schema); + if (Object.keys(metadata.definitions).length) schema.definitions = structuredClone(metadata.definitions); + schema.required = [...new Set([...(schema.required ?? []), "apiVersion", "kind", "metadata"])]; + schema.additionalProperties = false; + return {group, version: v.name, kind: names.kind, schema, file: `${group}/${names.kind.toLowerCase()}_${v.name}.json`}; + }); +} + +// A source carrying a sha256 is locked: different bytes are refused, not converted. +async function fetchPinned({url, sha256}) { + const bytes = await download(url); + const digest = crypto.createHash("sha256").update(bytes).digest("hex"); + if (sha256 && digest !== sha256) throw new Error(`${url}: sha256 ${digest}, locked ${sha256}`); + return {body: bytes.toString("utf8"), sha256: digest}; +} + +// Download CRD sources at their pinned commits and convert every served version. +// Returns the schema files and the records that lock them. +async function buildSchemas(sources, objectMeta) { + const meta = await fetchPinned(objectMeta); + const metadata = objectMetaSchema(JSON.parse(meta.body).definitions); + const files = new Map(); + const records = []; + for (const source of sources) { + if (!/^[a-f0-9]{40}$/.test(source.commit)) throw new Error("source must use a full commit SHA"); + const url = `https://raw.githubusercontent.com/${source.repository}/${source.commit}/${source.path}`; + const {body, sha256} = await fetchPinned({url, sha256: source.sha256}); + const schemas = YAML.parseAllDocuments(body).flatMap((doc) => { + if (doc.errors.length) throw doc.errors[0]; + const crd = doc.toJS(); + if (!crd) return []; + assertCrdSource(source, crd); + return crdSchemas(crd, metadata); + }); + if (!schemas.length) throw new Error(`${url}: no served schemas`); + for (const {file, schema} of schemas) { + if (files.has(file)) throw new Error(`duplicate schema ${file}`); + files.set(file, JSON.stringify(schema, null, 2) + "\n"); + } + records.push({...source, url, sha256, schemas: schemas.map(({file, group, version, kind}) => ({file, group, version, kind}))}); + } + return {files, objectMeta: {url: objectMeta.url, sha256: meta.sha256}, records}; +} + +module.exports = {assertCrdSource, convertSchema, crdSchemas, objectMetaSchema, buildSchemas}; diff --git a/hack/lib/crd-schema.test.js b/hack/lib/crd-schema.test.js new file mode 100644 index 0000000..ac19a15 --- /dev/null +++ b/hack/lib/crd-schema.test.js @@ -0,0 +1,79 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const Ajv = require("ajv"); +const {assertCrdSource, convertSchema, crdSchemas, objectMetaSchema} = require("./crd-schema"); + +const compile = (schema) => new Ajv({strict: false}).compile(schema); + +test("Modelsphere sources require their own API groups; external CRDs retain upstream groups", () => { + const source = {repository: "modelsphere/llm-operator", group: "autoscaling.modelsphere.dev", kind: "LLMScaler"}; + const crd = {kind: "CustomResourceDefinition", spec: {group: source.group, names: {kind: source.kind}}}; + assert.doesNotThrow(() => assertCrdSource(source, crd)); + assert.throws(() => assertCrdSource(source, {...crd, spec: {...crd.spec, group: "autoscaling.4pd.io"}}), /expected CRD/); + assert.throws(() => assertCrdSource({...source, group: "autoscaling.4pd.io"}, crd), /must use a .modelsphere.dev/); + assert.throws(() => assertCrdSource(source, {...crd, spec: {...crd.spec, names: {kind: "Wrong"}}}), /expected CRD/); + const external = {repository: "kubernetes-sigs/lws", group: "leaderworkerset.x-k8s.io", kind: "LeaderWorkerSet"}; + assert.doesNotThrow(() => assertCrdSource(external, + {kind: "CustomResourceDefinition", spec: {group: external.group, names: {kind: external.kind}}})); +}); + +test("strictness descends into array items while preserving typed maps and free objects", () => { + const validate = compile(convertSchema({type: "object", properties: { + env: {type: "array", items: {type: "object", properties: {name: {type: "string"}}, required: ["name"]}}, + labels: {type: "object", additionalProperties: {type: "string"}}, + free: {type: "object", "x-kubernetes-preserve-unknown-fields": true, properties: {known: {type: "string"}}} + }})); + assert(validate({env: [{name: "A"}], labels: {"example.com/key": "yes"}, free: {arbitrary: {nested: true}}})); + assert(!validate({env: [{name: "A", typo: true}]})); + assert(!validate({labels: {key: 1}})); + assert(!validate({free: {known: 1}})); +}); + +test("IntOrString and nullable fields retain valid Kubernetes values", () => { + const validate = compile(convertSchema({type: "object", properties: { + port: {"x-kubernetes-int-or-string": true, anyOf: [{type: "integer"}, {type: "string"}]}, + otherPort: {format: "int-or-string"}, + optional: {type: "string", enum: ["yes"], nullable: true} + }})); + assert(validate({port: 80, otherPort: "http", optional: null})); + assert(validate({port: "http", otherPort: 80, optional: "yes"})); + assert(!validate({port: false})); + assert(!validate({optional: "no"})); +}); + +test("CRD conversion publishes only served versions and enforces full GVK", () => { + const schemas = crdSchemas({kind: "CustomResourceDefinition", spec: { + group: "example.dev", names: {kind: "Example"}, versions: [ + {name: "v1", served: true, schema: {openAPIV3Schema: {type: "object", properties: {metadata: {type: "object"}}}}}, + {name: "v2", served: false} + ] + }}); + assert.deepEqual(schemas.map((s) => s.file), ["example.dev/example_v1.json"]); + const validate = compile(schemas[0].schema); + assert(validate({apiVersion: "example.dev/v1", kind: "Example", metadata: {name: "test"}})); + assert(!validate({apiVersion: "other.dev/v1", kind: "Example", metadata: {}})); + assert(!validate({apiVersion: "example.dev/v1", kind: "Wrong", metadata: {}})); + assert(!validate({apiVersion: "example.dev/v1", kind: "Example"})); +}); + +test("implicit metadata is supplied even for CRDs that omit it, with transitive references", () => { + const name = "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta"; + const metadata = objectMetaSchema({ + [name]: {type: "object", additionalProperties: false, properties: { + name: {type: "string"}, labels: {$ref: "#/definitions/Labels"} + }}, + Labels: {type: "object", additionalProperties: {type: "string"}}, + Unused: {type: "number"} + }); + assert.equal(metadata.definitions.Unused, undefined); + const [result] = crdSchemas({kind: "CustomResourceDefinition", spec: { + group: "example.dev", names: {kind: "Example"}, versions: [{name: "v1", served: true, + schema: {openAPIV3Schema: {type: "object", properties: {spec: {type: "object"}}}}}] + }}, metadata); + const validate = compile(result.schema); + assert(validate({apiVersion: "example.dev/v1", kind: "Example", metadata: {name: "x", labels: {key: "value"}}})); + assert(!validate({apiVersion: "example.dev/v1", kind: "Example", metadata: {typo: true}})); + assert(!validate({apiVersion: "example.dev/v1", kind: "Example", metadata: {labels: {key: 1}}})); +}); diff --git a/hack/lib/download.js b/hack/lib/download.js new file mode 100644 index 0000000..0fb45ac --- /dev/null +++ b/hack/lib/download.js @@ -0,0 +1,16 @@ +"use strict"; + +const http = require("node:http"); + +// fetch() ignores HTTPS_PROXY and friends unless told to, unlike curl, helm and +// kubeconform. Honor them the same way; without a proxy variable this changes nothing. +http.setGlobalProxyFromEnv?.(); + +// Covers the whole body: the helm archive is ~20 MB, slow through some proxies. +async function download(url) { + const response = await fetch(url, {signal: AbortSignal.timeout(5 * 60 * 1000)}); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} + +module.exports = {download}; diff --git a/hack/lib/helm-validation.js b/hack/lib/helm-validation.js new file mode 100644 index 0000000..e392f3b --- /dev/null +++ b/hack/lib/helm-validation.js @@ -0,0 +1,79 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const {spawnSync} = require("node:child_process"); +const YAML = require("yaml"); +const {modelNames, versionFiles} = require("./catalog"); + +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) || + ["package.json", "package-lock.json", ".github/workflows/lint.yml"].includes(file); +} + +function command(bin, args, options = {}) { + const result = spawnSync(bin, args, {encoding: "utf8", timeout: 120000, maxBuffer: 64 * 1024 * 1024, ...options}); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + return {stdout, stderr, ok: result.status === 0 && !result.error, + log: `$ ${bin} ${args.map((a) => JSON.stringify(a)).join(" ")}\n${stdout}${stderr}` + + (result.error ? `\n${result.error.message}\n` : "") + `\nexit: ${result.status}, signal: ${result.signal ?? "none"}\n`}; +} + +function git(root, args) { + const result = command("git", args, {cwd: root}); + if (!result.ok) throw new Error(result.log); + return result.stdout; +} + +// Keep both sides of renames for validator-change detection. NUL delimiters +// preserve filenames with whitespace; deleted versions are not selected. +// `added` lists the new versions, held to the chart minimum: a published version +// keeps the chart it was published with. Explicit files are checked as new. +function selectFiles(root, {all = false, base = "origin/HEAD", files = []} = {}) { + const models = path.join(root, "models"); + const versions = modelNames(models).flatMap((name) => versionFiles(name, models)); + if (all) return {files: versions, added: [], reason: "--all"}; + if (files.length) { + const selected = [...new Set(files.map((file) => path.relative(root, path.resolve(root, file)).split(path.sep).join("/")))].sort(); + if (selected.some((file) => !versions.includes(file))) throw new Error("explicit files must be existing model version YAMLs under models/"); + return {files: selected, added: selected, reason: "explicit files"}; + } + const mergeBase = git(root, ["merge-base", base, "HEAD"]).trim(); + const tokens = git(root, ["diff", "--name-status", "-z", "--find-renames", mergeBase, "HEAD"]).split("\0"); + const changed = []; + const added = []; + for (let i = 0; i < tokens.length && tokens[i];) { + const status = tokens[i++]; + changed.push(tokens[i++]); + if (/^[RC]/.test(status)) changed.push(tokens[i++]); + if (/^[ARC]/.test(status)) added.push(changed.at(-1)); + } + const among = (list) => versions.filter((file) => list.includes(file)); + if (changed.some(affectsValidator)) { + return {files: versions, added: among(added), reason: "validator/dependency/schema changes", mergeBase}; + } + return {files: among(changed), added: among(added), reason: `changes since merge-base with ${base}`, mergeBase}; +} + +function kubeconformArgs(config, schemaDir, cacheDir, rendered) { + // Local schemas use the FULL group. Native schemas also enforce apiVersion + // and kind enums, so a similarly named unknown CR cannot pass as a native kind. + return ["-strict", "-summary", "-verbose", "-output", "json", "-kubernetes-version", config.kubernetesVersion, + "-cache", cacheDir, + "-schema-location", `${schemaDir}/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json`, + "-schema-location", `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/${config.kubernetesSchemaCommit}/` + + "{{.NormalizedKubernetesVersion}}-standalone{{.StrictSuffix}}/{{.ResourceKind}}{{.KindSuffix}}.json", rendered]; +} + +// validate:schema owns a version file's shape; this only refuses to pass a file +// with nothing to render. +function parseVersions(file) { + const variants = YAML.parse(fs.readFileSync(file, "utf8"))?.variants; + if (!Array.isArray(variants) || !variants.length) throw new Error("variants: expected a nonempty array"); + return variants; +} + +module.exports = {command, selectFiles, affectsValidator, kubeconformArgs, parseVersions}; diff --git a/hack/lib/helm-validation.test.js b/hack/lib/helm-validation.test.js new file mode 100644 index 0000000..2f9cf91 --- /dev/null +++ b/hack/lib/helm-validation.test.js @@ -0,0 +1,96 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const {execFileSync} = require("node:child_process"); +const {selectFiles, kubeconformArgs} = require("./helm-validation"); +const config = require("../../schema/crds/config.json"); + +function repository(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "helm-selection-")); + t.after(() => fs.rmSync(dir, {recursive: true, force: true})); + const git = (...args) => execFileSync("git", args, {cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]}).trim(); + git("init", "-b", "main"); + git("config", "user.name", "Validator Test"); + git("config", "user.email", "validator@example.invalid"); + function write(file, body = "test\n") { + fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive: true}); + fs.writeFileSync(path.join(dir, file), body); + } + function commit() { git("add", "."); git("commit", "-m", "fixture"); } + write("models/example/example-1.0.0.yaml"); + write("models/example/metadata.yaml"); + write("hack/validate-helm.js"); + commit(); + git("branch", "base"); + return {dir, git, write, commit}; +} + +test("documentation and metadata changes select no version files", (t) => { + const r = repository(t); + r.write("README.md"); r.write("models/example/metadata.yaml", "modified\n"); r.commit(); + assert.deepEqual(selectFiles(r.dir, {base: "base"}).files, []); +}); + +test("add, modify and rename destinations are selected; deletions are excluded", (t) => { + const r = repository(t); + r.write("models/example/example-1.1.0.yaml"); r.commit(); + r.git("branch", "before"); + r.git("mv", "models/example/example-1.0.0.yaml", "models/example/renamed version-1.0.0.yaml"); + r.write("models/example/example-1.1.0.yaml", "modified\n"); + r.write("models/example/example-1.2.0.yaml"); r.commit(); + assert.deepEqual(selectFiles(r.dir, {base: "before"}).files, [ + "models/example/example-1.1.0.yaml", "models/example/example-1.2.0.yaml", "models/example/renamed version-1.0.0.yaml"]); + r.git("rm", "models/example/example-1.2.0.yaml"); r.commit(); + assert(!selectFiles(r.dir, {base: "before"}).files.includes("models/example/example-1.2.0.yaml")); +}); + +test("selection uses the common ancestor even when the target branch advances", (t) => { + const r = repository(t); + r.write("models/example/feature-1.0.0.yaml"); r.commit(); + r.git("checkout", "base"); r.write("models/example/base-only-1.0.0.yaml"); r.commit(); + r.git("checkout", "main"); + assert.deepEqual(selectFiles(r.dir, {base: "base"}).files, ["models/example/feature-1.0.0.yaml"]); +}); + +test("validator, dependency and schema changes, including deletions, trigger all versions", (t) => { + for (const file of ["hack/validate-helm.js", "hack/build-crds.js", "hack/lib/crd-schema.js", "schema/crds/config.json", "package-lock.json", ".github/workflows/lint.yml"]) { + const r = repository(t); + r.write(file, "changed\n"); r.commit(); + assert.equal(selectFiles(r.dir, {base: "base"}).reason, "validator/dependency/schema changes"); + } + const r = repository(t); + r.git("rm", "hack/validate-helm.js"); r.commit(); + assert.equal(selectFiles(r.dir, {base: "base"}).reason, "validator/dependency/schema changes"); + const renamed = repository(t); + renamed.git("mv", "hack/validate-helm.js", "archived-validator.txt"); renamed.commit(); + assert.equal(selectFiles(renamed.dir, {base: "base"}).reason, "validator/dependency/schema changes"); +}); + +test("only added versions are held to the chart minimum, also in a full scan", (t) => { + const r = repository(t); + r.write("models/example/example-1.0.0.yaml", "modified\n"); + r.write("models/example/example-1.1.0.yaml"); + r.write("hack/validate-helm.js", "changed\n"); r.commit(); + const selection = selectFiles(r.dir, {base: "base"}); + assert.deepEqual(selection.files, ["models/example/example-1.0.0.yaml", "models/example/example-1.1.0.yaml"]); + assert.deepEqual(selection.added, ["models/example/example-1.1.0.yaml"]); + assert.deepEqual(selectFiles(r.dir, {all: true}).added, []); + assert.deepEqual(selectFiles(r.dir, {files: ["models/example/example-1.0.0.yaml"]}).added, ["models/example/example-1.0.0.yaml"]); +}); + +test("unknown base fails rather than reporting no changes", (t) => { + const r = repository(t); + assert.throws(() => selectFiles(r.dir, {base: "missing-ref"})); +}); + +test("strict validation uses full group and pinned native schema without missing-schema bypass", () => { + const args = kubeconformArgs(config, "/schemas", "/cache", "/rendered.yaml"); + assert(args.includes("-strict")); + assert(!args.includes("-ignore-missing-schemas")); + assert(args.some((arg) => arg.includes("{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}"))); + assert(args.some((arg) => arg.includes(config.kubernetesSchemaCommit))); +}); diff --git a/hack/validate-helm.integration.test.js b/hack/validate-helm.integration.test.js new file mode 100644 index 0000000..25099f2 --- /dev/null +++ b/hack/validate-helm.integration.test.js @@ -0,0 +1,116 @@ +"use strict"; + +// Explicit, network-backed acceptance suite: npm run test:helm:integration. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const YAML = require("yaml"); +const {root} = require("./lib/catalog"); +const {main} = require("./validate-helm"); +const {command, kubeconformArgs} = require("./lib/helm-validation"); +const config = require("../schema/crds/config.json"); + +for (const chartMode of ["latest", "declared"]) { + test(`released charts (${chartMode}): fixed/range versions, all example variants, LWS, defaults and invalid configurations`, {timeout: 240000}, (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "helm-acceptance-")); + t.after(() => fs.rmSync(temp, {recursive: true, force: true})); + fs.mkdirSync(path.join(temp, "models/fixtures"), {recursive: true}); + fs.mkdirSync(path.join(temp, "schema")); + fs.symlinkSync(path.join(root, "schema/crds"), path.join(temp, "schema/crds"), "dir"); + fs.symlinkSync(path.join(root, ".cache"), path.join(temp, ".cache"), "dir"); + function write(name, doc) { + fs.writeFileSync(path.join(temp, `models/fixtures/${name}.yaml`), YAML.stringify(doc)); + } + const read = (file) => YAML.parse(fs.readFileSync(path.join(root, file), "utf8")); + for (const [name, file] of [ + ["valid-range", "models/glm5.3/glm5.3-1.0.1.yaml"], + ["valid-gemma", "models/gemma-4-31b-it/google-gemma-4-31b-it-1.0.0.yaml"], + ["valid-lws", "models/kimi-k2.5/kimi-k2.5-1.0.0.yaml"] + ]) write(name, read(file)); + // Published fixed pins predate the minimum; pin these copies to it. + const glm = read("models/glm5.3/glm5.3-1.0.0.yaml"); + for (const v of glm.variants) v.chart.version = config.minimumChartVersions.sglang; + write("valid-fixed", glm); + const vllm = read("models/kimi-k3/kimi-k3-1.0.1.yaml"); + vllm.variants = vllm.variants.filter((v) => v.chart.name === "vllm"); + for (const v of vllm.variants) v.chart.version = config.minimumChartVersions.vllm; + write("valid-vllm-fixed", vllm); + const base = {variants: [{id: "fixture", chart: {name: "sglang", version: ">=0.8.0"}}]}; + write("valid-defaults", base); + const invalids = [ + ["bad-key", {unkownField: true}, "lint"], + ["bad-type", {service: {port: "bad"}}, "lint"], + ["bad-env", {env: [{name: "TEST", value: "x", misspelled: true}]}, "kubeconform"], + ["bad-volume", {volumes: [{name: "bad", emptyDir: {misspelled: true}}]}, "kubeconform"], + ["bad-resources", {resources: {requsets: {cpu: "1"}}}, "kubeconform"], + ["bad-crd", {sloRequirement: {extraSpec: {unknownSpecField: true}}}, "kubeconform"], + ["bad-template", {sloRequirement: {extraSpec: {serviceId: "duplicate"}}}, "template"], + ["bad-explicit-route", {modelRoute: {nginx: {outputConfigMap: ""}}}, "template"] + ]; + for (const [name, values] of invalids) { + const doc = structuredClone(base); doc.variants[0].values = values; write(name, doc); + } + const noVersion = structuredClone(base); noVersion.variants[0].chart.version = "9999.0.0"; + write("bad-resolution", noVersion); + const legacy = structuredClone(base); legacy.variants[0].chart.version = "0.7.1"; + write("bad-legacy-chart", legacy); + const output = path.join(temp, "artifacts"); + // Explicit files are new versions, so the legacy fixture must fail the minimum. + const files = fs.readdirSync(path.join(temp, "models/fixtures")).map((name) => `models/fixtures/${name}`); + assert.equal(main({files, output, chartMode}, {repositoryRoot: temp}), false); + const summary = JSON.parse(fs.readFileSync(path.join(output, "summary.json"), "utf8")); + for (const result of summary.results.filter((r) => path.basename(r.file).startsWith("valid-"))) { + assert.equal(result.ok, true, `${result.file}: ${JSON.stringify(result)}`); + const expected = {LLMScaler: "autoscaling.modelsphere.dev/v1alpha1", LLMSLORequirement: "inference.modelsphere.dev/v1alpha1", + ModelRoute: "routing.modelsphere.dev/v1alpha1"}; + const rendered = YAML.parseAllDocuments(fs.readFileSync(path.join(temp, result.artifacts, "rendered.yaml"), "utf8")); + for (const doc of rendered) { + const resource = doc.toJS(); + if (expected[resource?.kind]) assert.equal(resource.apiVersion, expected[resource.kind], result.file); + } + } + const fixed = summary.results.find((r) => r.file.endsWith("valid-fixed.yaml")); + const vllmFixed = summary.results.find((r) => r.file.endsWith("valid-vllm-fixed.yaml")); + assert.equal(fixed.chart.declaredVersion, "0.8.0"); + assert.equal(vllmFixed.chart.declaredVersion, "0.5.0"); + if (chartMode === "declared") { + assert.equal(fixed.chart.version, "0.8.0"); + assert.equal(vllmFixed.chart.version, "0.5.0"); + } else { + // Inspect the same snapshot Helm searched, rather than hardcoding today's latest. + const index = YAML.parse(fs.readFileSync(path.join(temp, summary.runDirectory, "helm/cache/repository/catalog-index.yaml"), "utf8")); + const {compareVersions, isPrerelease} = require("./lib/catalog"); + for (const [name, pin] of [["sglang", fixed], ["vllm", vllmFixed]]) { + const latest = index.entries[name].filter((e) => !isPrerelease(e.version) && compareVersions(e.version, config.minimumChartVersions[name]) >= 0) + .sort((a, b) => compareVersions(b.version, a.version))[0].version; + assert.equal(pin.chart.version, latest); + assert(summary.results.filter((r) => r.ok && r.chart.name === name).every((r) => r.chart.version === latest)); + } + } + assert.equal(summary.results.filter((r) => r.file.endsWith("valid-gemma.yaml")).length, 2); + for (const [name, , stage] of invalids) { + const result = summary.results.find((r) => r.file.endsWith(`${name}.yaml`)); + assert.equal(result.ok, false, name); + assert.equal(result.stages[stage], false, `${name}: ${JSON.stringify(result)}`); + } + assert.match(summary.results.find((r) => r.file.endsWith("bad-resolution.yaml")).error, /no published/); + assert.match(summary.results.find((r) => r.file.endsWith("bad-legacy-chart.yaml")).error, /below the required minimum/); + // Verify the exact production kubeconform invocation fails for unknown GVKs, + // even if their kind resembles an existing native resource or known CRD. + const cache = path.join(temp, "schema-cache"); fs.mkdirSync(cache); + for (const [apiVersion, kind] of [ + ["unknown.example/v1", "Unknown"], ["wrong.example/v1", "ModelRoute"], + ["routing.modelsphere.dev/v9", "ModelRoute"], ["apps.wrong.example/v1", "Deployment"], + ["autoscaling.4pd.io/v1alpha1", "LLMScaler"], ["inference.x-k8s.io/v1alpha1", "LLMSLORequirement"], + ["routing.gpucluster.io/v1alpha1", "ModelRoute"] + ]) { + const rendered = path.join(temp, "unknown.yaml"); + fs.writeFileSync(rendered, YAML.stringify({apiVersion, kind, metadata: {name: "unknown"}})); + const result = command(path.join(root, ".cache/helm-validation/bin/kubeconform"), + kubeconformArgs(config, path.join(temp, summary.runDirectory, "crds"), cache, rendered)); + assert.equal(result.ok, false, `${apiVersion}/${kind}`); + } + }); +} diff --git a/hack/validate-helm.js b/hack/validate-helm.js new file mode 100644 index 0000000..fbe37d4 --- /dev/null +++ b/hack/validate-helm.js @@ -0,0 +1,249 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const YAML = require("yaml"); +const {root, compareVersions, isPrerelease} = require("./lib/catalog"); +const {command, selectFiles, kubeconformArgs, parseVersions} = require("./lib/helm-validation"); +const config = require("../schema/crds/config.json"); + +function options(args) { + const opts = {files: [], chartMode: "latest", output: path.join(root, "artifacts/helm-validation")}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--all") opts.all = true; + else if (["--base", "--output", "--chart-mode"].includes(arg)) { + if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`${arg}: missing argument`); + opts[arg === "--chart-mode" ? "chartMode" : arg.slice(2)] = args[++i]; + } else if (arg.startsWith("-")) throw new Error(`unknown option ${arg}`); + else opts.files.push(arg); + } + if ([Boolean(opts.all), Boolean(opts.base), Boolean(opts.files.length)].filter(Boolean).length > 1) { + throw new Error("choose one of --all, --base REF, or explicit files"); + } + opts.output = path.resolve(root, opts.output); + if (!["latest", "declared"].includes(opts.chartMode)) throw new Error("--chart-mode must be latest or declared"); + return opts; +} + +function main(opts, {repositoryRoot = root, runCommand = command} = {}) { + const root = repositoryRoot; + fs.mkdirSync(opts.output, {recursive: true}); + // Each invocation has isolated Helm settings and a fresh repository snapshot. + const runDir = fs.mkdtempSync(path.join(opts.output, "run-")); + const chartMode = opts.chartMode ?? "latest"; + const summary = {config, chartMode, files: [], results: []}; + const writeSummary = () => { + const body = JSON.stringify(summary, null, 2) + "\n"; + fs.writeFileSync(path.join(opts.output, "summary.json"), body); + fs.writeFileSync(path.join(runDir, "summary.json"), body); + }; + const log = (message) => { + console.log(message); + fs.appendFileSync(path.join(runDir, "validation.log"), message + "\n"); + }; + const bin = (name) => { + const installed = path.join(root, ".cache/helm-validation/bin", name); + return fs.existsSync(installed) ? installed : name; + }; + const env = {...process.env, HELM_CONFIG_HOME: path.join(runDir, "helm/config"), + HELM_CACHE_HOME: path.join(runDir, "helm/cache"), HELM_DATA_HOME: path.join(runDir, "helm/data"), + HELM_PLUGINS: path.join(runDir, "helm/plugins")}; + const invoke = (tool, args, dir, stage, options = {}) => { + const result = runCommand(tool, args, {cwd: root, env, ...options}); + fs.writeFileSync(path.join(dir, `${stage}.log`), result.log); + return result; + }; + const requireSuccess = (result, stage) => { + if (!result.ok) throw new Error(`${stage}: ${result.log}`); + return result.stdout; + }; + try { + if (!["latest", "declared"].includes(chartMode)) throw new Error("chartMode must be latest or declared"); + const selection = selectFiles(root, opts); + const added = new Set(selection.added); + Object.assign(summary, selection, {runDirectory: path.relative(root, runDir)}); + log(`Selection: ${selection.reason}${selection.mergeBase ? ` (${selection.mergeBase})` : ""}`); + if (!selection.files.length) { + log("No model version configuration changes (无版本配置变更)."); + return true; + } + log(`Validating ${selection.files.length} version files; chart mode ${chartMode}; Kubernetes ${config.kubernetesVersion}.`); + for (const [name, args] of [["helm", ["version", "--short"]], ["kubeconform", ["-v"]]]) { + const version = requireSuccess(invoke(bin(name), args, runDir, `${name}-version`), "tools").trim(); + if (!new RegExp(`^v${config[`${name}Version`].replaceAll(".", "\\.")}(?:\\+|$)`).test(version)) { + throw new Error(`${name}: expected ${config[`${name}Version`]}, got ${version}; run npm run helm:install`); + } + } + // CRD schemas are not committed: build them from the lock for this run, + // which must not rewrite it. + const schemaDir = path.join(runDir, "crds"); + // Several downloads, each allowed minutes: outlast them rather than kill the build. + requireSuccess(invoke(process.execPath, [path.join(__dirname, "build-crds.js"), "--locked", schemaDir], runDir, "crds", + {timeout: 30 * 60 * 1000}), "crds"); + // Each schema is /_.json: the API versions Helm may render. + const apis = fs.readdirSync(schemaDir).flatMap((group) => fs.readdirSync(path.join(schemaDir, group)) + .map((file) => `${group}/${file.slice(file.lastIndexOf("_") + 1, -".json".length)}`)); + const schemaCache = path.join(runDir, "schema-cache"); + fs.mkdirSync(schemaCache); + requireSuccess(invoke(bin("helm"), ["repo", "add", "catalog", config.chartRepository], runDir, "chart-repository"), "chart-repository"); + const index = YAML.parse(fs.readFileSync(path.join(env.HELM_CACHE_HOME, "repository/catalog-index.yaml"), "utf8")); + const requests = new Map(); + const resolutions = new Map(); + const packages = new Map(); + const chartDir = path.join(runDir, "charts"); + fs.mkdirSync(chartDir); + function resolveChart(name, constraint, dir, stage, stableOnly = false) { + const key = JSON.stringify([name, constraint, stableOnly]); + if (!resolutions.has(key)) { + const search = invoke(bin("helm"), ["search", "repo", `catalog/${name}`, "--versions", "--version", constraint, "--output", "json"], dir, stage); + // Helm searches descriptions as well as names. Retain its version order + // and require an exact name; latest checks never select prereleases. + const matches = JSON.parse(requireSuccess(search, stage)).filter((m) => + m.name === `catalog/${name}` && (!stableOnly || !isPrerelease(m.version))); + if (!matches.length) throw new Error(`chart.version: no published ${name} matches ${JSON.stringify(constraint)}${stableOnly ? " (stable releases only)" : ""}`); + resolutions.set(key, matches[0].version); + } + return resolutions.get(key); + } + function chartFor(chart, dir) { + const requestKey = JSON.stringify(chart); + if (requests.has(requestKey)) { + const cached = requests.get(requestKey); + if (cached instanceof Error) throw cached; + return cached; + } + try { + const minimum = config.minimumChartVersions[chart.name]; + if (!minimum) throw new Error(`chart.name: unsupported chart ${chart.name}`); + // Keep deployment declarations resolvable even when rendering against + // latest. A missing declared release must still fail CI; one below the + // minimum fails a new version only (see the variant loop). + const declaredVersion = resolveChart(chart.name, chart.version, dir, "declared-chart-resolution"); + const legacy = compareVersions(declaredVersion, minimum) < 0; + if (legacy && chartMode === "declared") { + // Its chart renders API groups with no schema here: nothing to render. + const pkg = {name: chart.name, declaredVersion, minimum, legacy}; + requests.set(requestKey, pkg); + return pkg; + } + const selectionConstraint = chartMode === "latest" ? `>=${minimum}` : chart.version; + const actualVersion = chartMode === "latest" ? + resolveChart(chart.name, selectionConstraint, dir, "latest-chart-resolution", true) : declaredVersion; + const key = `${chart.name}@${actualVersion}`; + if (!packages.has(key)) { + requireSuccess(invoke(bin("helm"), ["pull", `catalog/${chart.name}`, "--version", actualVersion, "--destination", chartDir], dir, "chart-download"), "chart-download"); + const archive = path.join(chartDir, `${chart.name}-${actualVersion}.tgz`); + const digest = crypto.createHash("sha256").update(fs.readFileSync(archive)).digest("hex"); + const published = index.entries[chart.name]?.find((entry) => entry.version === actualVersion); + if (!published?.digest || digest !== published.digest) throw new Error(`chart-download: ${key}: repository digest mismatch`); + const members = requireSuccess(invoke("tar", ["-tzf", archive], dir, "chart-package"), "chart-package").split("\n"); + if (!members.includes(`${chart.name}/values.schema.json`)) throw new Error(`${key}: published package is missing values.schema.json`); + const metadata = YAML.parse(requireSuccess(invoke(bin("helm"), ["show", "chart", archive], dir, "chart-metadata"), "chart-metadata")); + if (metadata.name !== chart.name || metadata.version !== actualVersion) throw new Error(`${key}: package metadata mismatch`); + packages.set(key, {archive, name: chart.name, actualVersion, digest: `sha256:${digest}`}); + } + const pkg = {...packages.get(key), declaredVersion, selectionConstraint, minimum, legacy}; + requests.set(requestKey, pkg); + return pkg; + } catch (err) { + requests.set(requestKey, err); + throw err; + } + } + for (const file of selection.files) { + let variants; + try { variants = parseVersions(path.join(root, file)); } + catch (err) { + log(`FAIL ${file} [parse]: ${err.message}`); + summary.results.push({file, stage: "parse", ok: false, error: err.message}); + continue; + } + for (const [index, variant] of variants.entries()) { + const label = `${file} :: ${variant.id} (variants[${index}])`; + const hash = crypto.createHash("sha256").update(file).digest("hex").slice(0, 12); + const dir = path.join(runDir, `${path.basename(file)}-${hash}`, variant.id); + fs.mkdirSync(dir, {recursive: true}); + const record = {file, variant: variant.id, variantIndex: index, declaredChart: variant.chart, + artifacts: path.relative(root, dir), stages: {}, ok: false}; + summary.results.push(record); + let stage = "values"; + try { + // JSON, not YAML: Helm reads YAML 1.1, where an unquoted on, no or yes is a boolean. + const valuesFile = path.join(dir, "values.json"); + fs.writeFileSync(valuesFile, JSON.stringify(variant.values ?? {}, null, 2) + "\n"); + stage = "chart"; + const pkg = chartFor(variant.chart, dir); + if (pkg.legacy) { + const below = `${pkg.name} ${pkg.declaredVersion} is below the required minimum ${pkg.minimum}`; + if (added.has(file)) throw new Error(`chart.version: ${below}`); + // A published version keeps its chart; rewriting it to pass would + // change the digest every existing deploy recorded. + record.warning = `published version declares ${below}`; + if (!pkg.archive) { + record.skipped = true; + log(`SKIP ${label}: ${record.warning}; its legacy chart cannot be rendered here`); + continue; + } + log(`WARN ${label}: ${record.warning}; its values are still checked against the latest chart`); + } + record.chart = {name: pkg.name, version: pkg.actualVersion, digest: pkg.digest, + mode: chartMode, declaredVersion: pkg.declaredVersion, selectionConstraint: pkg.selectionConstraint}; + log(`${label}: declared ${pkg.name} ${variant.chart.version} -> ${pkg.declaredVersion}; validating ${chartMode} chart ${pkg.name}@${pkg.actualVersion} ${pkg.digest}`); + fs.writeFileSync(path.join(dir, "chart.json"), JSON.stringify(record.chart, null, 2) + "\n"); + const shared = ["--values", path.join(root, "schema/crds/ci-values.yaml"), "--values", valuesFile, + "--kube-version", config.kubernetesVersion]; + // Run both stages even when lint fails: template can expose separate failures. + stage = "lint"; + const lint = invoke(bin("helm"), ["lint", pkg.archive, "--strict", ...shared], dir, "lint"); + record.stages.lint = lint.ok; + if (!lint.ok) log(`FAIL ${label} [lint]:\n${lint.log}`); + stage = "template"; + const template = invoke(bin("helm"), ["template", "catalog-ci", pkg.archive, "--namespace", "ci", ...shared, + ...apis.flatMap((api) => ["--api-versions", api])], dir, "template"); + record.stages.template = template.ok; + if (!template.ok) log(`FAIL ${label} [template]:\n${template.log}`); + const rendered = path.join(dir, "rendered.yaml"); + fs.writeFileSync(rendered, template.stdout); + if (template.ok) { + // An empty render must not turn a broken chart into a successful check. + stage = "template"; + const docs = YAML.parseAllDocuments(template.stdout); + if (docs.some((d) => d.errors.length) || !docs.some((d) => d.toJS()?.kind)) { + record.stages.template = false; + throw new Error("template: no Kubernetes resources or malformed rendered YAML"); + } + stage = "kubeconform"; + const kube = invoke(bin("kubeconform"), kubeconformArgs(config, schemaDir, schemaCache, rendered), dir, "kubeconform"); + record.stages.kubeconform = kube.ok; + if (!kube.ok) log(`FAIL ${label} [kubeconform]:\n${kube.log}`); + } + record.ok = ["lint", "template", "kubeconform"].every((name) => record.stages[name] === true); + if (record.ok) log(`PASS ${label}`); + } catch (err) { + record.error = err.message; + record.stage = stage; + fs.writeFileSync(path.join(dir, "error.log"), `[${stage}] ${err.message}\n`); + log(`FAIL ${label} [${stage}]: ${err.message}`); + } + } + } + const failed = summary.results.filter((r) => !r.ok && !r.skipped).length; + const skipped = summary.results.filter((r) => r.skipped).length; + log(`${summary.results.length} checks: ${summary.results.length - failed - skipped} passed, ${failed} failed` + + `${skipped ? `, ${skipped} skipped` : ""}. Artifacts: ${runDir}`); + return failed === 0; + } catch (err) { + summary.error = err.message; + log(`FAIL [setup]: ${err.message}`); + return false; + } finally { writeSummary(); } +} + +if (require.main === module) { + try { process.exitCode = main(options(process.argv.slice(2))) ? 0 : 1; } + catch (err) { console.error(err.message); process.exitCode = 1; } +} +module.exports = {options, main}; diff --git a/hack/validate-helm.test.js b/hack/validate-helm.test.js new file mode 100644 index 0000000..a3cb69e --- /dev/null +++ b/hack/validate-helm.test.js @@ -0,0 +1,157 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const YAML = require("yaml"); +const {main, options} = require("./validate-helm"); +const config = require("../schema/crds/config.json"); + +function fixture(t, failure, {chartName = "sglang", declaredVersion = "0.8.0", latestVersion = "0.8.0"} = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "helm-validator-")); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const file = "models/example/example-1.0.0.yaml"; + fs.mkdirSync(path.join(root, "models/example"), {recursive: true}); + fs.writeFileSync(path.join(root, file), YAML.stringify({variants: [ + {id: "default", default: true, chart: {name: chartName, version: declaredVersion}}, + {id: "other", chart: {name: chartName, version: `>=${config.minimumChartVersions[chartName]}`}, values: {extraArgs: ["--other"]}} + ]})); + const calls = []; + const bytes = Buffer.from("fixture-package"); + let prereleaseInserted = false; + const runCommand = (bin, args, opts) => { + calls.push(args); + let stdout = ""; + if (args[0] === "version") stdout = `v${config.helmVersion}+test`; + else if (args[0] === "-v") stdout = `v${config.kubeconformVersion}`; + else if (args[0] === "repo") { + const dir = path.join(opts.env.HELM_CACHE_HOME, "repository"); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync(path.join(dir, "catalog-index.yaml"), YAML.stringify({entries: {[chartName]: + [...new Set([latestVersion, declaredVersion])].map((version) => ({version, digest: crypto.createHash("sha256").update(bytes).digest("hex")})) + }})); + } else if (args[0] === "search") { + const constraint = args[args.indexOf("--version") + 1]; + const version = constraint === declaredVersion ? declaredVersion : latestVersion; + const matches = failure === "resolution" || failure === "latest-resolution" && constraint.startsWith(">=") ? [] : [{name: `catalog/${chartName}`, version}]; + if (matches.length) matches.unshift({name: `catalog/${chartName}-other`, version: "98.0.0"}); + // Inject a prerelease into the first latest lookup to exercise its filter. + if (failure === "prerelease" && constraint.startsWith(">=") && !prereleaseInserted) { + matches.unshift({name: `catalog/${chartName}`, version: "99.0.0-rc.1"}); + prereleaseInserted = true; + } + stdout = JSON.stringify(matches); + } + else if (args[0] === "pull") fs.writeFileSync(path.join(args.at(-1), `${chartName}-${args[args.indexOf("--version") + 1]}.tgz`), bytes); + else if (args[0] === "-tzf") stdout = failure === "schema" ? `${chartName}/Chart.yaml\n` : `${chartName}/Chart.yaml\n${chartName}/values.schema.json\n`; + else if (args[0] === "show") stdout = YAML.stringify({name: chartName, + version: path.basename(args.at(-1)).slice(chartName.length + 1, -4)}); + else if (args[0].endsWith("build-crds.js")) { + fs.mkdirSync(path.join(args.at(-1), "example.dev"), {recursive: true}); + fs.writeFileSync(path.join(args.at(-1), "example.dev/widget_v1alpha1.json"), "{}"); + } + else if (args[0] === "template") stdout = failure === "empty" ? "" : "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: example\n"; + const stage = args[0] === "-strict" ? "kubeconform" : args[0] === "pull" ? "download" : + args[0].endsWith("build-crds.js") ? "crds" : args[0]; + const ok = stage !== failure; + return {stdout, ok, log: ok ? stdout : `${stage}: fixture failure at spec.template.spec.containers[0].env[0]\n`}; + }; + const output = path.join(root, "artifacts"); + const run = (opts = {}) => main({all: true, files: [], output, ...opts}, {repositoryRoot: root, runCommand}); + const summary = () => JSON.parse(fs.readFileSync(path.join(output, "summary.json"), "utf8")); + return {run, summary, calls, root}; +} + +test("every variant uses independent values and the resolved package is reused", (t) => { + const f = fixture(t); + assert.equal(f.run(), true); + const s = f.summary(); + assert.equal(s.results.length, 2); + assert(s.results.every((r) => r.ok && r.chart.version === "0.8.0" && r.chart.digest.startsWith("sha256:"))); + assert.equal(f.calls.filter((a) => a[0] === "pull").length, 1); + const values = s.results.map((r) => JSON.parse(fs.readFileSync(path.join(f.root, r.artifacts, "values.json"), "utf8"))); + assert.equal(values[0].extraArgs, undefined); + assert.deepEqual(values[1].extraArgs, ["--other"]); + // CI values come first, so a variant's own value overrides them. + const ci = path.join(f.root, "schema/crds/ci-values.yaml"); + assert(f.calls.filter((a) => ["lint", "template"].includes(a[0])).every((a) => a.includes(config.kubernetesVersion) && + a.indexOf(ci) > 0 && a.indexOf(ci) < a.findIndex((arg) => arg.endsWith("values.json")))); + // Helm may render exactly the CRD API versions that were built. + assert(f.calls.filter((a) => a[0] === "template").every((a) => a[a.indexOf("--api-versions") + 1] === "example.dev/v1alpha1")); +}); + +for (const [chartName, declaredVersion, latestVersion] of [["sglang", "0.8.0", "1.2.0"], ["vllm", "0.5.0", "1.3.0"]]) { + test(`${chartName} latest mode checks a future stable release beyond a fixed pin`, (t) => { + const f = fixture(t, "prerelease", {chartName, declaredVersion, latestVersion}); + assert.equal(f.run(), true); + const s = f.summary(); + assert.equal(s.chartMode, "latest"); + assert(s.results.every((r) => r.chart.version === latestVersion)); + assert.equal(s.results[0].chart.declaredVersion, declaredVersion); + assert.equal(s.results[0].declaredChart.version, declaredVersion); + assert.equal(f.calls.filter((a) => a[0] === "pull").length, 1); + assert(f.calls.filter((a) => a[0] === "pull").every((a) => a.includes(latestVersion))); + assert(f.calls.filter((a) => a[0] === "search").every((a) => !a.includes("--devel"))); + }); +} + +test("declared mode checks the fixed deployment release independently of latest", (t) => { + const f = fixture(t, undefined, {latestVersion: "1.2.0"}); + assert.equal(f.run({chartMode: "declared"}), true); + assert.equal(f.summary().results[0].chart.version, "0.8.0"); + assert.equal(f.summary().results[0].chart.mode, "declared"); +}); + +test("a new version below the baseline cannot pass by checking a newer chart", (t) => { + const f = fixture(t, undefined, {declaredVersion: "0.7.1"}); + assert.equal(f.run({all: false, files: ["models/example/example-1.0.0.yaml"]}), false); + assert.match(f.summary().results[0].error, /below the required minimum 0.8.0/); +}); + +test("a published version below the baseline warns and its values are checked against latest", (t) => { + const f = fixture(t, undefined, {declaredVersion: "0.7.1", latestVersion: "0.8.2"}); + assert.equal(f.run(), true); + const [legacy] = f.summary().results; + assert.equal(legacy.ok, true); + assert.match(legacy.warning, /0.7.1 is below the required minimum 0.8.0/); + assert.equal(legacy.chart.version, "0.8.2"); +}); + +test("declared mode skips a published version below the baseline instead of rendering its legacy chart", (t) => { + const f = fixture(t, undefined, {declaredVersion: "0.7.1"}); + assert.equal(f.run({chartMode: "declared"}), true); + const [legacy, other] = f.summary().results; + assert.equal(legacy.skipped, true); + assert.equal(other.ok, true); + assert(f.calls.filter((a) => a[0] === "pull").every((a) => !a.includes("0.7.1"))); +}); + +test("missing latest release does not fall back to a valid declared pin", (t) => { + const f = fixture(t, "latest-resolution"); + assert.equal(f.run(), false); + assert(f.summary().results.every((r) => !r.ok)); + assert.equal(f.calls.filter((a) => a[0] === "pull").length, 0); +}); + +for (const failure of ["crds", "resolution", "download", "schema", "lint", "template", "kubeconform", "empty"]) { + test(`${failure} failure cannot report success`, (t) => { + const f = fixture(t, failure); + assert.equal(f.run(), false); + assert(f.summary().results.every((r) => !r.ok)); + if (failure === "lint") assert.equal(f.calls.filter((a) => a[0] === "template").length, 2); + if (failure === "template") assert.equal(f.calls.filter((a) => a[0] === "-strict").length, 0); + }); +} + +test("CLI rejects ambiguous selectors and incomplete arguments", () => { + assert.throws(() => options(["--all", "--base", "main"])); + assert.throws(() => options(["--base"])); + assert.throws(() => options(["--unknown"])); + assert.throws(() => options(["--chart-mode", "invalid"])); + assert.throws(() => options(["--chart-mode"])); + assert.equal(options([]).chartMode, "latest"); + assert.equal(options(["--all", "--chart-mode", "declared"]).chartMode, "declared"); +}); diff --git a/hack/validate-schema.js b/hack/validate-schema.js index 4165bb7..07953a8 100644 --- a/hack/validate-schema.js +++ b/hack/validate-schema.js @@ -67,7 +67,7 @@ function detail(err) { case "pattern": // The chart.version pattern is a generated grammar nobody reads. if (err.instancePath.endsWith("/chart/version")) { - return `must be a chart version or a range, e.g. 0.7.1, ">=0.7.1", "^0.7.1"${got}` + return `must be a chart version or a range, e.g. 0.8.0, ">=0.8.0", "^0.8.0"${got}` } return `must match /${params.pattern}/${got}` case "type": @@ -132,6 +132,24 @@ for (const check of checks) { } } +// What the schema cannot see: a variant id is unique within its version. A +// deploy, a tuning entry and validate:helm all name a variant by it. +for (const file of versionFilePaths) { + let variants + try { + variants = YAML.parse(fs.readFileSync(file, "utf8"))?.variants + } catch { + continue // a parse error is already reported above + } + if (!Array.isArray(variants)) continue + const ids = variants.map((v) => v?.id) + const twice = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))] + if (twice.length > 0) { + failed = true + report(path.relative(root, file), twice.map((id) => `variants: id ${JSON.stringify(id)} is used twice`)) + } +} + // What the schema cannot see: a tuning pair must name a published version of // its model, two different variants of that version, and a report that exists; // and a variant named as tuned must have its result recorded. swiss does not diff --git a/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.0.0.yaml b/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.0.0.yaml index 326f4b6..ff3c5ee 100644 --- a/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.0.0.yaml +++ b/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.0.0.yaml @@ -62,7 +62,7 @@ variants: ephemeral-storage: 16Gi limits: ephemeral-storage: 16Gi - progressDeadlineSeconds: 2000 + progressDeadlineSeconds: 7200 startupProbe: httpGet: path: /health diff --git a/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.1.0.yaml b/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.1.0.yaml index cd65b6f..9b1ae37 100644 --- a/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.1.0.yaml +++ b/models/qwen3.6-35b-a3b/qwen3.6-35b-a3b-1.1.0.yaml @@ -62,7 +62,7 @@ variants: ephemeral-storage: 16Gi limits: ephemeral-storage: 16Gi - progressDeadlineSeconds: 2000 + progressDeadlineSeconds: 7200 startupProbe: httpGet: path: /health diff --git a/package.json b/package.json index 50db4f8..20858e6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,11 @@ "scripts": { "test": "./hack/validate.sh", "validate:schema": "node hack/validate-schema.js", + "validate:helm": "node hack/validate-helm.js", + "helm:install": "node hack/install-helm-tools.js", + "helm:build-crds": "node hack/build-crds.js", + "test:helm": "node --test hack/lib/helm-validation.test.js hack/lib/crd-schema.test.js hack/validate-helm.test.js", + "test:helm:integration": "node --test hack/validate-helm.integration.test.js", "test:lib": "node --test 'hack/lib/*.test.js'", "prepare": "husky", "build:site": "node hack/build-site.js" diff --git a/schema/crds/README.md b/schema/crds/README.md new file mode 100644 index 0000000..c2aa21d --- /dev/null +++ b/schema/crds/README.md @@ -0,0 +1,74 @@ +# Helm validation + +`npm run validate:helm` renders every variant of the selected model versions +with a released chart and checks the result: `helm lint --strict`, +`helm template`, then `kubeconform -strict` against the Kubernetes and CRD +schemas. CI runs it on every PR as the `validate-helm` job. + +## Run it + +Needs Node.js 24, `git`, `tar` and network access. Downloads honor +`HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY`. + +```sh +npm ci +npm run helm:install # pinned helm and kubeconform +npm run validate:helm # versions changed since origin/HEAD +npm run validate:helm -- --base origin/master +npm run validate:helm -- --all +npm run validate:helm -- models/glm5.3/glm5.3-1.0.1.yaml +npm run validate:helm -- --all --chart-mode declared +npm run test:helm +npm run test:helm:integration # against released charts +``` + +Results go to `artifacts/helm-validation/`: `summary.json`, plus a `run-*` +directory holding each variant's values, rendered manifests and tool logs. + +## What is checked + +- **Versions**: those changed since the base. A change to the validator, this + directory or the dependencies checks every version. +- **Chart**: `--chart-mode latest` (the default, and CI's) renders the newest + stable chart at or above the minimum; `declared` renders the release the + YAML's `chart.version` resolves to. +- **Minimum chart version**: set in `config.json` (sglang 0.8.0, vllm 0.5.0, the + first releases on the `*.modelsphere.dev` API groups). A new version below it + fails. A published version below it only warns, or is skipped in declared mode: + published versions are never rewritten. +- **Values**: each variant's values, layered over + [`ci-values.yaml`](ci-values.yaml), which stands in for a deploy's site profile. + +## Files + +| File | Holds | +| --- | --- | +| [`config.json`](config.json) | tool versions and checksums, Kubernetes version, chart repository, minimum chart versions | +| [`ci-values.yaml`](ci-values.yaml) | values a site profile supplies, which CI has no site for | +| [`crds.json`](crds.json) | the CRDs to validate against, each pinned to an upstream commit | +| [`crds-lock.json`](crds-lock.json) | the sha256 of each pinned CRD and the schemas it yields; written by `build-crds` | + +## CRD schemas + +The schemas are not committed. `npm run helm:build-crds [-- DIR]` downloads each +CRD in `crds.json` and converts it into a strict kubeconform schema +([`hack/lib/crd-schema.js`](../../hack/lib/crd-schema.js)). It works like +`npm install`: a lock in step with `crds.json` is honored, and a download whose +bytes differ from it is refused; a missing or stale lock is rewritten. With +`--locked` it fails instead, like `npm ci`. That is how `validate:helm` builds +them, into its run directory. + +To change a CRD or the Kubernetes version, edit `crds.json` or `config.json`, +then: + +```sh +npm run helm:build-crds # rewrites crds-lock.json; commit it +npm run validate:helm -- --all +npm run validate:helm -- --all --chart-mode declared +``` + +## Limits + +This checks values and resource schemas offline. It does not run CEL, +admission webhooks or controllers, and does not check images, model paths or +GPUs. diff --git a/schema/crds/ci-values.yaml b/schema/crds/ci-values.yaml new file mode 100644 index 0000000..b537d96 --- /dev/null +++ b/schema/crds/ci-values.yaml @@ -0,0 +1,5 @@ +# What a deploy's site profile supplies and CI has no site for. validate:helm +# layers each variant's values over this file, so a variant's own value wins. +modelRoute: + nginx: + outputConfigMap: ci/openresty-conf diff --git a/schema/crds/config.json b/schema/crds/config.json new file mode 100644 index 0000000..c56b702 --- /dev/null +++ b/schema/crds/config.json @@ -0,0 +1,29 @@ +{ + "helmVersion": "4.3.0", + "kubeconformVersion": "0.8.0", + "kubernetesVersion": "1.36.3", + "chartRepository": "https://modelsphere.github.io/helm-charts/", + "minimumChartVersions": { + "sglang": "0.8.0", + "vllm": "0.5.0" + }, + "kubernetesSchemaCommit": "39a87cd29966bc0b3434e8cfbc2061782c8cb69a", + "toolChecksums": { + "darwin-arm64": { + "helm": "d3870437e1e95b67f8edbde964156c84a26503f560821d40c542441658934fba", + "kubeconform": "f84f4dfbebf4a6b0b230385fa065a39ea35e02608c2b50d025dcf64775a69d67" + }, + "darwin-amd64": { + "helm": "347a784877e0e20eac865e8d1c36a80f6bb0861d6f29abd34defb6570ef95d92", + "kubeconform": "71dbc87ac9f24099a62b93570e65aa06312ba6ac8aea63b7f86e9d999edf5a92" + }, + "linux-amd64": { + "helm": "86584a54def73570558f66f5111cc53dfed56689637ae32c1201205d494f54fb", + "kubeconform": "9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883" + }, + "linux-arm64": { + "helm": "31c5794dd55c66a51e6b7d2e2ac7a114ae8b1de41ff1d9ba51748ac973b06a08", + "kubeconform": "1f53fc8e81258197a35e8603054162a5af1de8c5af13746c71ab680d9534ed87" + } + } +} diff --git a/schema/crds/crds-lock.json b/schema/crds/crds-lock.json new file mode 100644 index 0000000..ed63b8b --- /dev/null +++ b/schema/crds/crds-lock.json @@ -0,0 +1,93 @@ +{ + "objectMeta": { + "url": "https://raw.githubusercontent.com/yannh/kubernetes-json-schema/39a87cd29966bc0b3434e8cfbc2061782c8cb69a/v1.36.3-standalone-strict/_definitions.json", + "sha256": "ed76a6bccfa27ff085c390952d64267a18c91beb045b3e33117d482e26510cf3" + }, + "sources": [ + { + "repository": "modelsphere/autoconfig", + "commit": "b07623f8179a31cd63545d4077fa21c253b608dd", + "path": "config/crd/bases/routing.modelsphere.dev_modelroutes.yaml", + "kind": "ModelRoute", + "group": "routing.modelsphere.dev", + "url": "https://raw.githubusercontent.com/modelsphere/autoconfig/b07623f8179a31cd63545d4077fa21c253b608dd/config/crd/bases/routing.modelsphere.dev_modelroutes.yaml", + "sha256": "ee2397f6a5636f585c5ec9b96abf46ef0b68df758beb94abd048236340554e2f", + "schemas": [ + { + "file": "routing.modelsphere.dev/modelroute_v1alpha1.json", + "group": "routing.modelsphere.dev", + "version": "v1alpha1", + "kind": "ModelRoute" + } + ] + }, + { + "repository": "modelsphere/llm-operator", + "commit": "3583a1fae4b979fd506f81be9317d3316395fc00", + "path": "config/crd/bases/autoscaling.modelsphere.dev_llmscalers.yaml", + "kind": "LLMScaler", + "group": "autoscaling.modelsphere.dev", + "url": "https://raw.githubusercontent.com/modelsphere/llm-operator/3583a1fae4b979fd506f81be9317d3316395fc00/config/crd/bases/autoscaling.modelsphere.dev_llmscalers.yaml", + "sha256": "72168fc74a2603ad726ad6a85a2cb7737d105cc4e3e8eeea4284ff87f3a85214", + "schemas": [ + { + "file": "autoscaling.modelsphere.dev/llmscaler_v1alpha1.json", + "group": "autoscaling.modelsphere.dev", + "version": "v1alpha1", + "kind": "LLMScaler" + } + ] + }, + { + "repository": "modelsphere/helm-charts", + "commit": "8c2ca99e4576aafbdd17107a3c55fb391b2473c4", + "path": "charts/llm-slo-decision-gen/crds/llmslorequirements.inference.modelsphere.dev.yaml", + "kind": "LLMSLORequirement", + "group": "inference.modelsphere.dev", + "url": "https://raw.githubusercontent.com/modelsphere/helm-charts/8c2ca99e4576aafbdd17107a3c55fb391b2473c4/charts/llm-slo-decision-gen/crds/llmslorequirements.inference.modelsphere.dev.yaml", + "sha256": "a16199665872f1d2b78cb008bcfff3d232db59595e1f8c820f648c85720a511a", + "schemas": [ + { + "file": "inference.modelsphere.dev/llmslorequirement_v1alpha1.json", + "group": "inference.modelsphere.dev", + "version": "v1alpha1", + "kind": "LLMSLORequirement" + } + ] + }, + { + "repository": "kubernetes-sigs/lws", + "commit": "03254992e177a706fc531572376a3d3153addc93", + "path": "config/crd/bases/leaderworkerset.x-k8s.io_leaderworkersets.yaml", + "kind": "LeaderWorkerSet", + "group": "leaderworkerset.x-k8s.io", + "url": "https://raw.githubusercontent.com/kubernetes-sigs/lws/03254992e177a706fc531572376a3d3153addc93/config/crd/bases/leaderworkerset.x-k8s.io_leaderworkersets.yaml", + "sha256": "1554930d16a4c48bbfca4952afbf877c2814f9f751e0f9694e6c22ef3206a518", + "schemas": [ + { + "file": "leaderworkerset.x-k8s.io/leaderworkerset_v1.json", + "group": "leaderworkerset.x-k8s.io", + "version": "v1", + "kind": "LeaderWorkerSet" + } + ] + }, + { + "repository": "prometheus-operator/prometheus-operator", + "commit": "5b656b46d40ee3de0c444e55bce82871b42aa959", + "path": "example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml", + "kind": "ServiceMonitor", + "group": "monitoring.coreos.com", + "url": "https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/5b656b46d40ee3de0c444e55bce82871b42aa959/example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml", + "sha256": "31bdff398b54ac2bc993a33f5dcb0472792a77104a6a609d264c8f1f17829769", + "schemas": [ + { + "file": "monitoring.coreos.com/servicemonitor_v1.json", + "group": "monitoring.coreos.com", + "version": "v1", + "kind": "ServiceMonitor" + } + ] + } + ] +} diff --git a/schema/crds/crds.json b/schema/crds/crds.json new file mode 100644 index 0000000..1d20a07 --- /dev/null +++ b/schema/crds/crds.json @@ -0,0 +1,39 @@ +{ + "sources": [ + { + "repository": "modelsphere/autoconfig", + "commit": "b07623f8179a31cd63545d4077fa21c253b608dd", + "path": "config/crd/bases/routing.modelsphere.dev_modelroutes.yaml", + "kind": "ModelRoute", + "group": "routing.modelsphere.dev" + }, + { + "repository": "modelsphere/llm-operator", + "commit": "3583a1fae4b979fd506f81be9317d3316395fc00", + "path": "config/crd/bases/autoscaling.modelsphere.dev_llmscalers.yaml", + "kind": "LLMScaler", + "group": "autoscaling.modelsphere.dev" + }, + { + "repository": "modelsphere/helm-charts", + "commit": "8c2ca99e4576aafbdd17107a3c55fb391b2473c4", + "path": "charts/llm-slo-decision-gen/crds/llmslorequirements.inference.modelsphere.dev.yaml", + "kind": "LLMSLORequirement", + "group": "inference.modelsphere.dev" + }, + { + "repository": "kubernetes-sigs/lws", + "commit": "03254992e177a706fc531572376a3d3153addc93", + "path": "config/crd/bases/leaderworkerset.x-k8s.io_leaderworkersets.yaml", + "kind": "LeaderWorkerSet", + "group": "leaderworkerset.x-k8s.io" + }, + { + "repository": "prometheus-operator/prometheus-operator", + "commit": "5b656b46d40ee3de0c444e55bce82871b42aa959", + "path": "example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml", + "kind": "ServiceMonitor", + "group": "monitoring.coreos.com" + } + ] +} diff --git a/schema/version.schema.json b/schema/version.schema.json index 6073cb7..29783d8 100644 --- a/schema/version.schema.json +++ b/schema/version.schema.json @@ -92,7 +92,7 @@ ] }, "version": { - "description": "One chart version, or a range of them in helm's constraint syntax. A bare version pins exactly: 0.7.1 is 0.7.1 and nothing else. A range lets a chart fix reach this model version without publishing a new one: >=0.7.1, ^0.7.1 (0.7.x from 0.7.1), >=0.7.1 <0.9.0. A deploy records the one version the range resolved to, so the deploy itself stays pinned. Prereleases match only a range that names one.", + "description": "One chart version, or a range of them in helm's constraint syntax. A bare version pins exactly: 0.8.0 is 0.8.0 and nothing else. A range lets a chart fix reach this model version without publishing a new one: >=0.8.0, ^0.8.0 (0.8.x from 0.8.0), >=0.8.0 <0.9.0. A deploy records the one version the range resolved to, so the deploy itself stays pinned. Prereleases match only a range that names one.", "type": "string", "pattern": "^(v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?\\s+-\\s+v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?|(=|!=|>=?|<=?|~>?|\\^)?\\s*v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?)((\\s*,\\s*|\\s+)(v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?\\s+-\\s+v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?|(=|!=|>=?|<=?|~>?|\\^)?\\s*v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?))*(\\s*\\|\\|\\s*(v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?\\s+-\\s+v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?|(=|!=|>=?|<=?|~>?|\\^)?\\s*v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?)((\\s*,\\s*|\\s+)(v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?\\s+-\\s+v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?|(=|!=|>=?|<=?|~>?|\\^)?\\s*v?(\\d+|[xX*])(\\.(\\d+|[xX*])){0,2}(-[0-9A-Za-z.-]+)?(\\+[0-9A-Za-z.-]+)?))*)*$" }