Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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

Expand Down
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

50 changes: 50 additions & 0 deletions hack/build-crds.js
Original file line number Diff line number Diff line change
@@ -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;});
41 changes: 41 additions & 0 deletions hack/install-helm-tools.js
Original file line number Diff line number Diff line change
@@ -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;});
10 changes: 5 additions & 5 deletions hack/lib/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<name>-<version>.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}`);
Expand Down
127 changes: 127 additions & 0 deletions hack/lib/crd-schema.js
Original file line number Diff line number Diff line change
@@ -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};
Loading
Loading