Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e710fa6
docs: bring in the multi-node spec and implementation plan
codemug Aug 31, 2026
c71ce37
feat(souslet): add gRPC wire contract and generated code
codemug Aug 31, 2026
de924af
fix(souslet): pin protoc version and fix go.mod indirect dependencies
codemug Sep 1, 2026
0b532ff
feat(mtls): self-issued CA for per-node souslet client certs
codemug Sep 1, 2026
9a9bb4d
feat(nodecatalog): in-memory per-node state, level-triggered replace
codemug Sep 1, 2026
a2f6e41
feat(grpcserver): API-side Souslet service, snapshot ingestion, corre…
codemug Sep 1, 2026
39f9bbe
fix(grpcserver): stop leaking the write-loop goroutine on node discon…
codemug Sep 1, 2026
d31c26c
fix(grpcserver): unblock Send when a node disconnects while a reply i…
codemug Sep 1, 2026
697fa40
feat(grpcclient): dispatch Envelope commands to local engine/fetch
codemug Sep 1, 2026
45fcd3a
feat(grpcclient): cache declared footprint so Snapshot reports real W…
codemug Sep 1, 2026
aca0336
feat(souslet): connect/reconnect loop and the souslet binary
codemug Sep 1, 2026
ecabbac
fix(souslet): reset reconnect backoff on a genuinely healthy connection
codemug Sep 1, 2026
1e66a1c
feat(httpapi): route deploy/undeploy/plan through grpcserver, node-sc…
codemug Sep 1, 2026
ea43778
fix(httpapi): guard nil gsrv/nodes, restore WarnFreeGiB for node-scop…
codemug Sep 1, 2026
9e34866
feat(sous-api): control-plane binary, persisted node CA
codemug Sep 1, 2026
47b4527
feat(gateway): proxy inference traffic over the souslet gRPC connection
codemug Sep 1, 2026
6ce610d
fix(gateway): chunk proxied request bodies, stop relaying on client d…
codemug Sep 1, 2026
fa75005
feat(deploy): fetch weights first when not yet cached on the target node
codemug Sep 1, 2026
33e5494
fix(deploy): poll FetchCommand until done, fix Connect's catalog/conn…
codemug Sep 1, 2026
b97eb27
fix(grpcclient): HandleFetch checks Status before Start, so polling o…
codemug Sep 1, 2026
a5a5711
feat(weights): recipe-card cleanup replaces the per-node larder page
codemug Sep 1, 2026
b2f5f69
fix(weights): restore archived-recipe rollback protection at sous-api
codemug Sep 1, 2026
f643ff5
fix(weights): browser form wiring + active-reference guard tier
codemug Sep 1, 2026
98f2481
feat(ui): per-node dashboard cards for the multi-node fleet
codemug Sep 1, 2026
cd250ca
fix(ui): stop fleet-card segments from claiming false readiness
codemug Sep 1, 2026
9a59429
feat(ui): drag-and-drop recipe deploy onto node capacity cards
codemug Sep 1, 2026
0039a08
fix(ui): withhold drag-and-drop deploy from archived recipes
codemug Sep 1, 2026
5bce138
chore: remove single-node Sous, larder, and old deploy.Manager; add n…
codemug Sep 1, 2026
1f940c8
docs: point README at the two-image sous-api/souslet build
codemug Sep 1, 2026
ef0f0dd
fix(mtls): give sous-api a real server certificate, and save CA state…
codemug Sep 1, 2026
76edbb3
fix(grpcserver): verify node identity, survive reconnects, and stop l…
codemug Sep 1, 2026
ef030a7
fix(souslet): keep re-reporting node state instead of snapshotting on…
codemug Sep 1, 2026
76662de
fix(souslet): allocate a real host port for node-scoped deploys
codemug Sep 1, 2026
ef3a075
fix(gateway): wire the multi-node proxy into the real server, and enf…
codemug Sep 1, 2026
bf8e6b7
docs: describe souslet's port range and its continuous state reporting
codemug Sep 1, 2026
2e27cdc
fix(souslet): reject a non-positive -port-low, and drop a doc comment…
codemug Sep 1, 2026
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
25 changes: 20 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ jobs:
permissions:
contents: read
packages: write
# TWO IMAGES, one per binary: sous-api (the control plane) and souslet
# (the per-node worker), each with its own Dockerfile building its own
# cmd/ entrypoint. Matrixed rather than duplicated as two jobs, since
# every step past "which Dockerfile/image name" is identical.
strategy:
matrix:
include:
- binary: sous-api
dockerfile: Dockerfile.sous-api
image: ghcr.io/codemug/sous-api
- binary: souslet
dockerfile: Dockerfile.souslet
image: ghcr.io/codemug/sous-souslet
steps:
- uses: actions/checkout@v4

Expand All @@ -53,7 +66,7 @@ jobs:
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
images: ${{ matrix.image }}
# WHAT A DEPLOYMENT SHOULD PIN: a semver tag (0.1.0). It is
# immutable, it maps to a GitHub release you can read, and it makes
# "what is running" answerable from the compose file alone.
Expand All @@ -71,12 +84,13 @@ jobs:
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.binary }}
cache-to: type=gha,mode=max,scope=${{ matrix.binary }}

# A tag should produce something a human can read, not just a moving image.
# Deployments pin a version; this is where you find out what that version
Expand Down Expand Up @@ -107,10 +121,11 @@ jobs:
fi

{
echo "## Container image"
echo "## Container images"
echo
echo '```'
echo "ghcr.io/${GITHUB_REPOSITORY}:${TAG#v}"
echo "ghcr.io/codemug/sous-api:${TAG#v}"
echo "ghcr.io/codemug/sous-souslet:${TAG#v}"
echo '```'
echo
echo "linux/amd64 and linux/arm64."
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ progress.txt
# Session scratch from the agent harness, not source.
.omc/
**/.omc/

# Protoc build artifacts (downloaded binary, not source)
.bin/
44 changes: 0 additions & 44 deletions Dockerfile

This file was deleted.

50 changes: 50 additions & 0 deletions Dockerfile.sous-api
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# sous-api is the control plane: recipe catalog, node catalog, UI, and the
# mTLS gRPC server every souslet dials into. One static binary plus embedded
# templates, so the runtime layer needs almost nothing. Built on the target
# arch via buildx; CI publishes linux/amd64 and linux/arm64 since this
# fleet's nodes are a mix of both.
FROM golang:1.25-alpine AS build

WORKDIR /src

# Dependencies first: they change far less often than the source, so this
# layer survives most rebuilds.
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO off so the result is genuinely static and runs on a distroless-style
# base. -trimpath keeps build paths out of the binary; -s -w drops the
# symbol table and DWARF, which is a meaningful size cut for something with
# no debugger attached in production.
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -trimpath -ldflags="-s -w" -o /out/sous-api ./cmd/sous-api

# ---------------------------------------------------------------------------

FROM alpine:3.21

# ca-certificates: sous-api fetches model metadata from HuggingFace over TLS.
# git: recipe sources are git mirrors, and sous-api shells out to git for
# them (souslet, by contrast, receives recipes as YAML over gRPC and has no
# source-mirroring feature of its own - see Dockerfile.souslet).
# tzdata: deployment timestamps are rendered in local time.
RUN apk add --no-cache ca-certificates git tzdata

COPY --from=build /out/sous-api /usr/local/bin/sous-api

# sous-api stores everything on disk deliberately - a broken install must be
# repairable with an editor - so this is a mount point, not a place to write
# into the image. It also persists the node CA here by default
# (-ca-state), which souslet certs are signed against.
VOLUME ["/var/lib/sous-api"]

# No EXPOSE: both the HTTP (-listen) and gRPC (-grpc-listen) listen
# addresses are required configuration with no default, because binding
# either on a component that can start and stop models - or accept mTLS
# connections that can drive that same machinery remotely - would remove the
# only mitigation either one has.

ENTRYPOINT ["/usr/local/bin/sous-api"]
52 changes: 52 additions & 0 deletions Dockerfile.souslet
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# souslet is the per-node worker: it holds no UI, no HTTP server, and no
# persistent store of its own - only a Docker engine wrapper, a weight-fetch
# manager, and a gRPC client that dials sous-api and stays connected for the
# process lifetime. One static binary plus embedded templates would be
# overkill; this has none, but the build shape matches sous-api's for the
# same reason: buildx on the target arch, since this fleet's nodes are a mix
# of amd64 (aorus-ubuntu, uae-homenode) and arm64 (asus-gx10).
FROM golang:1.25-alpine AS build

WORKDIR /src

# Dependencies first: they change far less often than the source, so this
# layer survives most rebuilds.
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO off so the result is genuinely static and runs on a distroless-style
# base. -trimpath keeps build paths out of the binary; -s -w drops the
# symbol table and DWARF, which is a meaningful size cut for something with
# no debugger attached in production.
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \
go build -trimpath -ldflags="-s -w" -o /out/souslet ./cmd/souslet

# ---------------------------------------------------------------------------

FROM alpine:3.21

# ca-certificates: souslet's gRPC dial to sous-api is mTLS against a custom
# CA supplied via -ca, but the container it spawns to fetch weights
# (huggingface_hub) still makes its own outbound HTTPS calls that expect a
# normal system trust store.
# tzdata: deployment timestamps souslet reports are rendered in local time.
# Deliberately NOT git: unlike sous-api, souslet has no recipe-source-mirror
# feature of its own - recipes arrive as YAML over gRPC (DeployCommand
# carries the whole recipe precisely so souslet needs no catalog, and by
# extension no source mirror, of its own).
RUN apk add --no-cache ca-certificates tzdata

COPY --from=build /out/souslet /usr/local/bin/souslet

# souslet is stateless by design (see the package doc comment: "if it and
# its whole host reboot, the only source of truth it needs is what Docker is
# actually running right now") - no VOLUME, because there is nothing on this
# node's own disk it is responsible for persisting.

# No EXPOSE: souslet dials OUT to sous-api (-api-addr) and never listens for
# inbound connections at all.

ENTRYPOINT ["/usr/local/bin/souslet"]
38 changes: 38 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
PROTOC_VERSION := 27.0
PROTOC_GEN_GO_VERSION := v1.34.2
PROTOC_GEN_GO_GRPC_VERSION := v1.5.1

# Determine platform and download protoc
PROTOC_BIN := $(CURDIR)/.bin/protoc
PROTOC_DOWNLOADED := $(CURDIR)/.bin/protoc-$(PROTOC_VERSION).downloaded

$(PROTOC_DOWNLOADED):
@mkdir -p $(CURDIR)/.bin
@OS=$$(uname -s | tr A-Z a-z); \
ARCH=$$(uname -m); \
case "$$ARCH" in x86_64) ARCH=x86_64;; aarch64|arm64) ARCH=aarch_64;; esac; \
PLATFORM="$$OS-$$ARCH"; \
URL="https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/protoc-$(PROTOC_VERSION)-$$PLATFORM.zip"; \
echo "Downloading protoc $(PROTOC_VERSION) for $$PLATFORM..."; \
cd $(CURDIR)/.bin && curl -sL -o protoc-$(PROTOC_VERSION).zip "$$URL" || (echo "Failed to download protoc"; exit 1); \
unzip -q protoc-$(PROTOC_VERSION).zip && rm -f protoc-$(PROTOC_VERSION).zip; \
chmod +x bin/protoc; \
touch $(PROTOC_DOWNLOADED)

$(PROTOC_BIN): $(PROTOC_DOWNLOADED)
@if [ ! -f $(PROTOC_BIN) ]; then \
ln -s $(CURDIR)/.bin/bin/protoc $(PROTOC_BIN) || cp $(CURDIR)/.bin/bin/protoc $(PROTOC_BIN); \
fi

.PHONY: proto
proto: $(PROTOC_BIN)
go install google.golang.org/protobuf/cmd/protoc-gen-go@$(PROTOC_GEN_GO_VERSION)
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@$(PROTOC_GEN_GO_GRPC_VERSION)
$(PROTOC_BIN) \
--go_out=. --go_opt=module=github.com/codemug/sous \
--go-grpc_out=. --go-grpc_opt=module=github.com/codemug/sous \
proto/souslet/v1/souslet.proto

.PHONY: test
test:
go test ./...
86 changes: 72 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ fits before you start it, deploy it on a free port, and see what it actually did

[![build](https://github.com/codemug/sous/actions/workflows/build.yml/badge.svg)](https://github.com/codemug/sous/actions/workflows/build.yml)
[![Go](https://img.shields.io/badge/go-1.25-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![image](https://img.shields.io/badge/ghcr.io-codemug%2Fsous-2496ED?logo=docker&logoColor=white)](https://github.com/codemug/sous/pkgs/container/sous)
[![arch](https://img.shields.io/badge/arch-amd64%20%7C%20arm64-4FB9A6)](https://github.com/codemug/sous/pkgs/container/sous)
[![image](https://img.shields.io/badge/ghcr.io-codemug%2Fsous--api-2496ED?logo=docker&logoColor=white)](https://github.com/codemug/sous/pkgs/container/sous-api)
[![arch](https://img.shields.io/badge/arch-amd64%20%7C%20arm64-4FB9A6)](https://github.com/codemug/sous/pkgs/container/sous-api)

</div>

Expand Down Expand Up @@ -72,31 +72,89 @@ for it.

## Quickstart

Two binaries now: `sous-api` is the control plane (one instance — the catalog, the UI, and the
mTLS gRPC server every node dials into), `souslet` runs on every node that actually serves
models. `sous-api` alone, with no `souslet` connected, is a working single-node control plane —
it still deploys locally as a fallback — but a real fleet needs at least one `souslet`.

**1. Start `sous-api`:**

```bash
docker run -d --name sous \
docker run -d --name sous-api \
--privileged \
--network host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /opt/sous:/var/lib/sous \
-v /opt/sous-api:/var/lib/sous-api \
-v /models:/models \
ghcr.io/codemug/sous:latest \
ghcr.io/codemug/sous-api:latest \
-listen 10.0.0.5:8090 \
-grpc-listen 10.0.0.5:8091 \
-ca-state /var/lib/sous-api/ca-state.json \
-models /models
```

Then open `http://10.0.0.5:8090`. The catalog seeds itself on first run.

**`-listen` is required and refuses `0.0.0.0`.** Sous creates and destroys containers, which
makes it root-equivalent on its node; the network boundary is the mitigation, so binding
everything would remove the only protection it has.
**2. Register a node and copy its cert onto it.** `node add` is a subcommand of the same binary,
run against the same `-ca-state` file the running server uses:

```bash
docker exec sous-api sous-api node add \
-ca-state /var/lib/sous-api/ca-state.json -out /var/lib/sous-api/certs asus-gx10
```

That writes `ca.pem`, `asus-gx10.cert.pem` and `asus-gx10.key.pem` under `/var/lib/sous-api/certs`
inside the container — `/opt/sous-api/certs` on the host, per the volume above. Copy those three
files onto the node. **Restart `sous-api`** after adding a node: it loaded its CA into memory at
startup, so it will not accept a connection signed for a node added since.

**Why `--privileged`:** Sous drops page cache before every model start, and `/proc/sys` is
read-only inside Docker. Without it, the next model sizes its KV cache against memory the
kernel is holding — a real OOM, not a theoretical one. If that trade is unacceptable in your
environment, run the binary under systemd instead; it needs no container.
**3. Start `souslet`** on that node, pointed at the cert material you just copied over:

```bash
docker run -d --name souslet \
--network host \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /models:/models \
-v /opt/souslet/certs:/certs:ro \
ghcr.io/codemug/sous-souslet:latest \
-api-addr 10.0.0.5:8091 \
-node-id asus-gx10 \
-model-dir /models \
-ca /certs/ca.pem \
-cert /certs/asus-gx10.cert.pem \
-key /certs/asus-gx10.key.pem \
-pool-gib 128
```

**Mount the model cache at the same path inside and out.** Sous hands paths to the Docker
daemon, and the daemon resolves them on the *host*.
`-pool-gib` is this node's real usable memory, not the nominal spec figure — a box specced at
128 GiB commonly reports less once the OS and firmware take their share, and planning against
the nominal number over-commits before anything is even deployed. `souslet` has no UI and no
listener of its own; it dials `-api-addr` and stays connected, reconnecting with backoff if that
drops, and re-reports its full state every few seconds so the control plane's view of the node
never goes stale between deploys.

`souslet` picks the host port each model publishes on, from `-port-low`/`-port-high`
(18000–18100 by default) on `-bind-host` (127.0.0.1 by default) — availability is decided by
actually binding, which only means anything on the machine the container runs on. Clients never
need those ports: they go through `sous-api`'s one OpenAI-compatible endpoint, which routes to
whichever node is running the model they named.

**`-listen`/`-grpc-listen` (on `sous-api`) are both required and both refuse `0.0.0.0`.** Sous
creates and destroys containers — directly on `sous-api`'s own box via its local fallback path,
and remotely on every connected node once `souslet` is deployed there — which makes either
listener root-equivalent-by-proxy; the network boundary is the mitigation, so binding everything
would remove the only protection either one has.

**Why `--privileged` on `sous-api`:** its local deploy path drops page cache before every model
it starts on its own box, and `/proc/sys` is read-only inside Docker without it. Without that
drop, the next model sizes its KV cache against memory the kernel is still holding — a real OOM,
not a theoretical one. `souslet` does not need `--privileged` today; its deploy path does not yet
carry this same cache-drop step. If `--privileged` is unacceptable in your environment, run the
binary under systemd instead; it needs no container.

**Mount the model cache (`-models`/`-model-dir`) at the same path inside and out, on every box
running one of these images.** Sous hands paths to the Docker daemon, and the daemon resolves
them on the *host*.

## Design decisions worth knowing

Expand Down
Loading
Loading