From 92517ed45db8628cc297d6e524badc9a97a2502e Mon Sep 17 00:00:00 2001 From: huskcass <333684917+huskcass@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:13:55 -0700 Subject: [PATCH 1/2] docs: slim swiss-design.md into architecture.md and add DEVELOPMENT.md --- AGENTS.md | 2 +- DEVELOPMENT.md | 62 ++++++++++++++++++++++ README.md | 4 +- docs/architecture.md | 72 ++++++++++++++++++++++++++ docs/swiss-design.md | 120 ------------------------------------------- 5 files changed, 137 insertions(+), 123 deletions(-) create mode 100644 DEVELOPMENT.md create mode 100644 docs/architecture.md delete mode 100644 docs/swiss-design.md diff --git a/AGENTS.md b/AGENTS.md index 7e3aac0..80af381 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - Minimal. Only where the *why* cannot be read off the code. - No narrative paragraphs above functions, no package essays, no restating the next line. -- Rationale belongs in `docs/swiss-design.md`, not in source. Code goes stale silently; the doc can be edited. +- Rationale belongs in `docs/architecture.md`, not in source. Code goes stale silently; the doc can be edited. ## Formatting diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..e74ff71 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,62 @@ +# Development + +How the pieces fit: [docs/architecture.md](docs/architecture.md). + +## Prerequisites + +- Go (version in `go.mod`), helm, node/npm for the UI +- For e2e: docker, kind, kubectl +- The UI source is a submodule: `git submodule update --init` + +## Build + +```sh +go build -o swiss ./cmd/swiss +go build -o swissd ./cmd/swissd +hack/web.sh # build the UI into web/dist, which swissd embeds +make docker-build # swissd image, IMG=swissd:e2e by default +``` + +`web/dist` is committed empty, so `go build` works without the UI. + +## Run locally + +```sh +./swissd --config examples/swiss.yaml +./swiss catalog list +``` + +Configuration: [README.md](README.md#configure). + +## Unit tests + +```sh +make test # go test ./... +``` + +CI checks out [model-catalog](https://github.com/modelsphere/model-catalog) to `../swiss-catalog` (pinned by `CATALOG_REF` in `.github/workflows/ci.yml`); the server tests read it from there. + +## e2e tests (kind) + +```sh +make test-e2e # KEEP_CLUSTER=1 leaves the cluster up +make cleanup-test-e2e +``` + +Details and settings: [docs/e2e.md](docs/e2e.md). + +## Lint + +What CI runs: + +```sh +gofmt -l cmd internal helm test web/embed.go # must print nothing +make vet # go vet, including -tags=e2e +helm lint --strict helm/swiss --set 'rbac.namespaces={ci}' +``` + +## Pull requests + +1. Fork, branch from `master`. +2. Run the unit tests and lint above. +3. Open a PR against `modelsphere/swiss` `master`. CI (`ci.yml`) must pass; `e2e.yml` runs too but is not required. diff --git a/README.md b/README.md index 9a643db..4dc7b1b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![stars](https://img.shields.io/github/stars/modelsphere/swiss?style=flat&logo=github)](https://github.com/modelsphere/swiss/stargazers) A deploy control plane for the sglang and vllm charts. Design and rationale: -[`docs/swiss-design.md`](docs/swiss-design.md). +[`docs/architecture.md`](docs/architecture.md). Building and testing: [`DEVELOPMENT.md`](DEVELOPMENT.md). ## Configure @@ -68,7 +68,7 @@ PUT /api/profile POST /api/plans /api/diff /api/apply /api/install (allowDeploy only) ``` -`applyWith` on the site profile chooses the backend for a new install: `helm` (the default when the field is omitted) or `llmsvc` when the LLMService CRD is served. A release already in the cluster stays on the backend that owns it. `llmsvc` requires the plan's `source.schema` to be v2; empty means v1 and is rejected with 400 and nothing is written. Helm accepts v1 and v2. The CLI `swiss install` and `swiss apply` stay on helm. See [docs/swiss-design.md](docs/swiss-design.md). +`applyWith` on the site profile chooses the backend for a new install: `helm` (the default when the field is omitted) or `llmsvc` when the LLMService CRD is served. A release already in the cluster stays on the backend that owns it. `llmsvc` requires the plan's `source.schema` to be v2; empty means v1 and is rejected with 400 and nothing is written. Helm accepts v1 and v2. The CLI `swiss install` and `swiss apply` stay on helm. See [docs/architecture.md](docs/architecture.md). ## Deploy diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..dff92ed --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,72 @@ +# Swiss architecture + +Swiss is a deploy control plane for the sglang and vllm charts. It composes a +model release from a public catalog, a site profile and deploy-time settings, +then hands it to helm. It feeds the existing charts, `helmfile.yaml` and the +`Makefile` targets rather than replacing them. + +- **Built:** the catalog, `swiss` (CLI), `swissd` (HTTP server with an embedded + web UI, write path behind `server.allowDeploy`). +- **Not built:** preflight, `emit`, auth. + +## Components + +| piece | artifact | runtime | +| --- | --- | --- | +| **swiss-catalog** | published static files, data only | none | +| **core** | Go module, `internal/` | library | +| **`swiss`** | binary | CLI, stateless | +| **`swissd`** | binary | HTTP server | +| **web UI** | React + TypeScript | embedded into `swissd` | + +Three things are deployed: the catalog, `swissd`, and the engine charts in the +helm repo (`https://modelsphere.github.io/helm-charts` by default). The catalog +is its own project: [model-catalog](https://github.com/modelsphere/model-catalog). + +```mermaid +flowchart LR + catalog[(swiss-catalog)] --> core + profile[(site profile)] --> core + cli[swiss CLI] --> core + ui[web UI] --> swissd --> core + core[core: compose, diff, apply] --> helm[helm / helmfile] + core --> llmsvc[LLMService] + helm --> charts[sglang / vllm charts] +``` + +## Layers + +Composition is a few inputs with key sets and strict precedence. Later layer overrides. + +| layer | owns | +| --- | --- | +| helm chart defaults | things defaults in sglang/vllm chart values.yaml | +| **site defaults** (`defaults`) | the site's preferences, config it in `defaults.features` in the profile, the `cart`, `serviceMonitor`, `sloRequirement` and `scaler` `enabled` flags, unset on | +| **catalog variant** | model deployment recipes define in [model-catalog](https://github.com/modelsphere/model-catalog) and how it parallelizes: `model.*`, `extraArgs`, `lws.*`, `env`, `image.repository` | +| **site profile** | what makes it work here: the registry mirror, cache paths, route ConfigMaps, namespace, the `model.localPath` template, the default scheduler and priority class | +| **derived** | swiss's rules, above the variant: a section the form filled in is switched on, `cache.maxSlotsPerNode` | +| **user form** | how much, where, how routed: `serviceId`, `replicaCount`, `scaler.*`, `modelRoute.*`, scheduling | +| **plan editor** | the escape hatch, applied last — any key, exempt from ownership, labelled `edit` | + +## Pipeline + +``` +catalog entry ─┐ +site profile ─┼─► compose ─► Plan ─► diff ─► apply | install ─► helm +form / flags ─┘ (hash) ↓ + revision lock +``` + +`applyWith` on the site profile picks the backend for a new install: `helm` (default) or `llmsvc` (LLMService). A release already in the cluster stays on the backend that owns it. + +| endpoint | | +| --- | --- | +| `POST /api/plans` | compose; returns the plan and stores it by hash | +| `POST /api/diff` | diff a stored plan against the live release | +| `POST /api/apply` | upgrade, asserting the diffed revision when one is supplied | +| `POST /api/install` | create | +| `DELETE /api/releases/{ns}/{release}` | uninstall | +| `POST /api/releases/{ns}/{release}/chat` | health check: one real inference request | +| `GET /api/runs`, `GET /api/runs/{id}` | the operation log; output is per row, not in the list | +| `GET /api/nodes` | the GPU inventory: type, allocatable, in use, and what holds it | +| `GET /api/profile` | the site profile as parsed, after defaults | diff --git a/docs/swiss-design.md b/docs/swiss-design.md deleted file mode 100644 index 67519b3..0000000 --- a/docs/swiss-design.md +++ /dev/null @@ -1,120 +0,0 @@ -# Swiss: a deploy control plane for the sglang and vllm charts - -Swiss composes a model release from a public catalog, a site profile and -deploy-time settings, then hands it to helm. It feeds the existing charts, -`helmfile.yaml` and the `Makefile` targets rather than replacing them. - -- **Built:** the catalog, `swiss` (CLI), `swissd` (HTTP server with an embedded - web UI, write path behind `server.allowDeploy`). -- **Not built:** preflight, `emit`, auth. - -## Components - -| piece | artifact | runtime | -| --- | --- | --- | -| **swiss-catalog** | published static files, data only | none | -| **core** | Go module, `internal/` | library | -| **`swiss`** | binary | CLI, stateless | -| **`swissd`** | binary | HTTP server | -| **web UI** | React + TypeScript | embedded into `swissd` | - -Three things are deployed: the catalog, `swissd`, and the engine charts in the -helm repo (`https://modelsphere.github.io/helm-charts` by default). - -## Layers - -Composition is a few inputs with key sets and strict precedence. Later layer overrides. - - -| layer | owns | -| --- | --- | -| helm chart defaults | things defaults in sglang/vllm chart values.yaml | -| **site defaults** (`defaults`) | the site's preferences, config it in `defaults.features` in the profile, the `cart`, `serviceMonitor`, `sloRequirement` and `scaler` `enabled` flags, unset on | -| **catalog variant** | model deployment recipes define in [model-catalog](https://github.com/modelsphere/model-catalog) and how it parallelizes: `model.*`, `extraArgs`, `lws.*`, `env`, `image.repository` | -| **site profile** | what makes it work here: the registry mirror, cache paths, route ConfigMaps, namespace, the `model.localPath` template, the default scheduler and priority class | -| **derived** | swiss's rules, above the variant: a section the form filled in is switched on, `cache.maxSlotsPerNode` | -| **user form** | how much, where, how routed: `serviceId`, `replicaCount`, `scaler.*`, `modelRoute.*`, scheduling | -| **plan editor** | the escape hatch, applied last — any key, exempt from ownership, labelled `edit` | - - -## Pipeline - -``` -catalog entry ─┐ -site profile ─┼─► compose ─► Plan ─► diff ─► apply | install ─► helm -form / flags ─┘ (hash) ↓ - revision lock -``` - -| endpoint | | -| --- | --- | -| `POST /api/plans` | compose; returns the plan and stores it by hash | -| `POST /api/diff` | diff a stored plan against the live release | -| `POST /api/apply` | upgrade, asserting the diffed revision when one is supplied | -| `POST /api/install` | create | -| `DELETE /api/releases/{ns}/{release}` | uninstall; a helm release stays listed as `uninstalling` until helm has removed it and its pods are gone | -| `POST /api/releases/{ns}/{release}/chat` | health check: one real inference request | -| `GET /api/runs`, `GET /api/runs/{id}` | the operation log; output is per row, not in the list | -| `GET /api/nodes` | the GPU inventory: type, allocatable, in use, and what holds it | -| `GET /api/profile` | the site profile as parsed, after defaults | - - -## Uninstalling - -A helm release stays on `GET /api/deployments` with `phase: uninstalling` until helm has removed the release and its pods are gone. `DELETE` returns when helm does; it does not wait for those pods to finish terminating. - -| | | -| --- | --- | -| marker | plan ConfigMap `status.yaml` `phase: uninstalling`, plus label `swiss.modelsphere.dev/phase=uninstalling` so the metadata-only list can see it | -| memory | `namespace/release` on the process; `inflight` while that helm call has not returned, so a list cannot delete the plan underneath it | -| rebuild | the background loop at startup, and the first deployments list, fill that set from ConfigMaps carrying the label | -| drop | helm no longer has the release, and no pod matches `app=-` (when the plan names a chart) or `app.kubernetes.io/instance=` | -| check | every 5s from `Server.Run`, and again when the deployments list or that release's status is read | - -``` -DELETE - ├─ no plan ConfigMap ─► helm uninstall ─► delete the ConfigMap - └─ plan ConfigMap - write phase + label - helm uninstall - ├─ error ─► phase failed, clear the label, keep the ConfigMap - └─ ok ─► delete archive Secrets - helm release still recorded, or pods match either selector - ├─ yes ─► stay listed (phase uninstalling) - └─ no ─► delete the ConfigMap and drop the row -``` - -| situation | result | -| --- | --- | -| helm uninstall fails | `phase: failed` and the error; the plan stays; the row does not stay `uninstalling` | -| DELETE again, helm release already gone | 200, `output` is `uninstall already in progress` | -| DELETE again, helm release still recorded | helm uninstall runs again | -| install, upgrade, or rollback of that name | 409 `release / is uninstalling` | -| ConfigMap delete fails | the row stays; the next check retries | -| pod list fails, or reading the helm release fails | the row stays | -| helm release still recorded and no pods (restart mid-uninstall) | the row stays `uninstalling`; the next DELETE runs helm again | -| swissd restarts | the label is read back; the row stays until the helm release and its pods are both gone | - -An LLMService with `metadata.deletionTimestamp` set reports `phase: uninstalling` on the deployments row and `llmservice.phase` on status, whatever `status.phase` says. Install, upgrade, and rollback of that name are 409. `DELETE` of an LLMService still waits until the object is gone. - -`phase` gains the value `uninstalling` on a deployment row, on helm `planStatus.phase`, and on `llmservice.phase`. No other field on those objects changes. - - -## Catalog - -See [model-catalog](https://github.com/modelsphere/model-catalog). - -**A writable catalog is edited through swissd.** A profile entry with -`writable: true` is a catalog-server (schema v2) that takes writes on its -`api/v2/`. swissd stands in for the catalog's address and passes that API -through as it is -- no endpoint of its own to keep in step with catalog-server's, -and it checks nothing itself: catalog-server holds the rules. - -``` -/api/catalogs//api/v2/ e.g. POST init (setup), PUT catalog (settings), - POST models, PUT models//metadata, PUT models//variants/ - profile catalogs[].writable else 403 - Secret[catalog..token] else 409 - ──▶ api/v2/, query, headers and body as sent, Authorization: Bearer - a write's 2xx ──▶ drop the cached index, so the next read shows the commit -``` From 9d764d08a25c7314286ebd15183350d5af32c42e Mon Sep 17 00:00:00 2001 From: huskcass <333684917+huskcass@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:21:48 -0700 Subject: [PATCH 2/2] docs: note that local make test needs model-catalog in ../swiss-catalog --- DEVELOPMENT.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e74ff71..3a92934 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,7 +34,12 @@ Configuration: [README.md](README.md#configure). make test # go test ./... ``` -CI checks out [model-catalog](https://github.com/modelsphere/model-catalog) to `../swiss-catalog` (pinned by `CATALOG_REF` in `.github/workflows/ci.yml`); the server tests read it from there. +The server tests read [model-catalog](https://github.com/modelsphere/model-catalog) from `../swiss-catalog` and fail without it. Clone it there at the `CATALOG_REF` pinned in `.github/workflows/ci.yml` (CI does the same): + +```sh +git clone https://github.com/modelsphere/model-catalog ../swiss-catalog +git -C ../swiss-catalog checkout +``` ## e2e tests (kind)