diff --git a/.dockerignore b/.dockerignore index eea428f..c1671f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,5 +18,5 @@ docs/ # NOT examples/: sandd/Cargo.toml declares [[example]] targets at ../examples/*.rs, # and cargo parses every workspace member's manifest even when building one package. # Excluding this directory makes the workspace fail to parse in any image that builds -# from source (Dockerfile.controller, Dockerfile.server-tunnel). +# from source (e.g. Dockerfile.server-tunnel). python/tests/ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7a7c2aa..a05c3e4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,13 +24,9 @@ jobs: - runner: ubuntu-22.04 target: x86_64-unknown-linux-musl asset: sandd-linux-amd64 - package: sandd - bin: sandd - runner: ubuntu-22.04-arm target: aarch64-unknown-linux-musl asset: sandd-linux-arm64 - package: sandd - bin: sandd # No Intel-mac (x86_64-apple-darwin) leg: GitHub is retiring the # macos-13 (Intel) runners, so that job queues indefinitely and — with # `release` gated on `needs: build` — blocks the whole release from @@ -38,19 +34,6 @@ jobs: - runner: macos-14 target: aarch64-apple-darwin asset: sandd-darwin-arm64 - package: sandd - bin: sandd - # THE DAEMON ONLY. There is deliberately no sandd-controller asset and no - # controller image: the controller is not a deployed artifact any more. Its - # only consumer, Nebula, compiles it INTO its manager process through the C - # ABI (server/src/ffi.rs), because a daemon's connection is a live socket - # owned by whichever process accepted it — so reaching back into a workload - # from a separate controller process would mean relaying. - # - # `cargo build --bin sandd-controller` and `make docker-build-controller` - # still work for anyone who wants to run it standalone. They are just not - # release artifacts, and publishing them would imply a supported deployment - # shape that nothing uses. steps: - uses: actions/checkout@v4 @@ -64,13 +47,11 @@ jobs: if: endsWith(matrix.target, '-musl') run: sudo apt-get update && sudo apt-get install -y musl-tools - - name: Build ${{ matrix.bin }} - run: | - cargo build --package ${{ matrix.package }} --bin ${{ matrix.bin }} \ - --release --locked --target ${{ matrix.target }} + - name: Build daemon + run: cargo build --package sandd --release --locked --target ${{ matrix.target }} - name: Rename binary - run: mv target/${{ matrix.target }}/release/${{ matrix.bin }} ${{ matrix.asset }} + run: mv target/${{ matrix.target }}/release/sandd ${{ matrix.asset }} - name: Verify static linking if: endsWith(matrix.target, '-musl') diff --git a/Cargo.lock b/Cargo.lock index 7c85e52..1399b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2065,7 +2065,6 @@ dependencies = [ "anyhow", "axum", "base64", - "clap", "dashmap", "futures 0.3.32", "futures-util", diff --git a/Makefile b/Makefile index daa4078..1823353 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ MATURIN := .venv/bin/maturin # Pinned so lint results don't shift when ruff changes its default rule set. RUFF_VERSION := ruff==0.15.15 -.PHONY: help build install dev test clean daemon-build daemon-release controller-build controller-release test-e2e test-e2e-tunnel docker-build docker-down +.PHONY: help build install dev test clean daemon-build daemon-release test-e2e test-e2e-tunnel docker-build docker-down help: @echo "SandD - Sandbox Daemon - Build Commands" @@ -18,17 +18,10 @@ help: @echo " make test-e2e-tunnel - Run tunnel-mode (Tailscale mesh) e2e tests (slow)" @echo " make daemon-build - Build daemon binary (debug)" @echo " make daemon-release - Build daemon binary (release)" - @echo " make controller-build - Build controller binary (debug)" - @echo " make controller-release - Build controller binary (release)" @echo " make docker-build - Build Docker image for daemon" @echo " make docker-down - Stop and remove Docker containers" @echo " make clean - Clean build artifacts" @echo "" - @echo "Controller image (native Rust binary — what Nebula runs):" - @echo " make docker-build-controller - Build both arches (no push)" - @echo " make docker-build-controller-local - Build host arch only, load locally" - @echo " make docker-push-controller - Build both arches and push a manifest" - @echo "" @echo "Tunnel server (Python-hosted controller) image — multi-arch (amd64 + arm64):" @echo " make docker-build-server-tunnel - Build both arches (no push)" @echo " make docker-build-server-tunnel-local - Build host arch only, load locally" @@ -47,11 +40,8 @@ test: lint $(PYTEST) dev @echo "Running Rust tests (daemon)..." cargo test --package sandd @echo "" - @echo "Running Rust tests (controller: lib + binary)..." - # NOT --lib: that skips src/main.rs, where the controller's config/flag rules and - # the "auth on but material missing must be FATAL" tests live. --no-default-features - # is implicit (python is off by default), which is also what the bin target needs. - cargo test --package sandbox-server --lib --bins + @echo "Running Rust tests (controller)..." + cargo test --package sandbox-server --lib @echo "" @echo "Running Python tests (excluding e2e)..." $(PYTEST) python/tests/ -m "not e2e" @@ -64,17 +54,6 @@ daemon-release: @echo "" @echo "SandD binary built at: ./target/release/sandd" -# The CONTROLLER binary — what Nebula runs, one Deployment per workload. No -# --features python: the pyo3 layer must stay out or the bin cannot link at all -# (extension-module leaves the CPython symbols undefined). See server/Cargo.toml. -controller-build: - cargo build --package sandbox-server --bin sandd-controller - -controller-release: - cargo build --package sandbox-server --bin sandd-controller --release - @echo "" - @echo "SandD controller binary built at: ./target/release/sandd-controller" - clean: cargo clean rm -rf target/ @@ -114,61 +93,10 @@ docker-up: docker-down: docker compose -f hack/docker/docker-compose.e2e.yml down -# --- Controller image (native Rust binary) ------------------------------------- -# -# For running the controller STANDALONE. Nebula does not pull this: it links the -# controller into its own manager through the C ABI (server/src/ffi.rs), so there is -# no controller Deployment and no image to pin. Nothing publishes this image either -# — the release workflow ships the daemon binaries only. -# -# Distroless + one static-ish binary, ~50MB against the ~4GB server-tunnel image -# below, because it carries no interpreter, no rustup and no Tailscale client. -# -# Multi-arch for the same reason as every other image here (see the note below): -# built on arm64 Macs, deployed to mostly-amd64 nodes. -CONTROLLER_IMG ?= inftyai/sandd-controller -CONTROLLER_TAG ?= latest - -.PHONY: docker-build-controller -docker-build-controller: buildx-builder - docker buildx build \ - --builder $(BUILDX_BUILDER) \ - --platform $(PLATFORMS) \ - -f hack/docker/Dockerfile.controller \ - -t $(CONTROLLER_IMG):$(CONTROLLER_TAG) \ - . - -# Host arch only, loaded into the local docker store so it can actually be run -# (`docker run --rm $(CONTROLLER_IMG):$(CONTROLLER_TAG) --help`). A multi-platform -# build cannot be --load'ed: the local store holds one arch per tag. -.PHONY: docker-build-controller-local -docker-build-controller-local: buildx-builder - docker buildx build \ - --builder $(BUILDX_BUILDER) \ - -f hack/docker/Dockerfile.controller \ - -t $(CONTROLLER_IMG):$(CONTROLLER_TAG) \ - --load \ - . - -.PHONY: docker-push-controller -docker-push-controller: buildx-builder - docker buildx build \ - --builder $(BUILDX_BUILDER) \ - --platform $(PLATFORMS) \ - -f hack/docker/Dockerfile.controller \ - -t $(CONTROLLER_IMG):$(CONTROLLER_TAG) \ - --push \ - . - @echo "" - @echo "Pushed $(CONTROLLER_IMG):$(CONTROLLER_TAG) for $(PLATFORMS)" - @echo "Verify the manifest lists both arches:" - @echo " docker buildx imagetools inspect $(CONTROLLER_IMG):$(CONTROLLER_TAG)" - # --- Tunnel server (controller) image ------------------------------------------ # # The PYTHON-hosted controller, kept for the tunnel/mesh e2e stacks and for driving a -# controller from Python (`from sandd import Server`). Nebula uses the native binary -# above instead — no Python drives its controllers and it does not use the mesh. +# controller from Python (`from sandd import Server`). # # The controller image must run on BOTH architectures: it is developed on arm64 # Macs but deployed to clusters whose nodes are usually amd64 (and increasingly diff --git a/go/controller/controller.go b/go/controller/controller.go deleted file mode 100644 index 92b5eb9..0000000 --- a/go/controller/controller.go +++ /dev/null @@ -1,656 +0,0 @@ -/* -Copyright 2026 The InftyAI Team. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Package controller runs the SandD controller inside a Go process. -// -// The controller — the WebSocket server daemons dial into, the registry that holds their -// sockets, and the token verification that admits them — is this repo's Rust -// implementation, linked in as a static archive and driven through its C ABI -// (server/src/ffi.rs). Nothing here reimplements the protocol: this package is a safe Go -// skin over pointers, the same way python/ is a safe Python skin over the same registry. -// -// It lives in THIS repo, beside the code it wraps, so an ABI change and its binding move -// in one commit. A copy maintained in a consumer would drift silently — a linker catches -// a missing symbol, never a changed meaning. -// -// # Why a host embeds this instead of calling a controller over the network -// -// A daemon's connection is a live socket in whichever process called accept(), and it -// cannot be observed or handed to another process. A host can therefore reach a daemon -// only by holding the socket itself or by asking the holder over the network. Embedding -// removes that second process — for Nebula, a Deployment, the public/private signing-key -// split, and the kid/iss/aud agreement between two processes that has historically been -// the most common misconfiguration. -// -// WHAT EMBEDDING COSTS, so it is not discovered later: -// - Shared crash domain. A panic in the Rust half takes the host process down with it. -// - Whatever public route reaches the controller now terminates on the host, which for -// Nebula is the pod holding the private signing key. -// - The host's memory limit must cover every daemon socket (see Config.Bind). -// -// # Linking -// -// The archive must be built with the ffi feature: -// -// cargo build -p sandbox-server --features ffi --lib --release -// -// and found at link time, e.g. CGO_LDFLAGS=-L/target/release. Built for a musl -// target it links FULLY STATIC, so a cgo host keeps a self-contained binary and can still -// ship on a distroless/static base. -// -// # Concurrency and lifetime -// -// The C ABI cannot make use-after-free unrepresentable, so this package does: handles -// live behind a mutex-guarded pointer that is nil'd on Close, every entry point checks -// it, and a closed handle returns ErrClosed rather than dereferencing freed memory. That -// is the whole reason this file exists instead of callers using cgo directly. -// -// Nil'ing the pointer is NOT sufficient on its own. Exec and Session.Read park in C for -// up to their timeout and must not hold the mutex while they do — otherwise one idle -// terminal serializes every other caller — so they copy the handle and release the lock. -// A concurrent Close would then free a handle a blocked call is still using, and on the -// server that means dropping the tokio runtime the call is executing on. Both types -// therefore count in-flight calls and Close WAITS for that count to drain before -// freeing. Consequence for callers: Close can block for as long as the longest -// outstanding timeout, and must not be called while holding a lock a reader needs. -// -// A Server must outlive every Session opened from it — a Session borrows the server's -// tokio runtime handle. Server.Close closes all sessions, and waits for each, for -// exactly that reason. -package controller - -/* -#cgo LDFLAGS: -lsandbox_server -lm -#include -#include - -typedef struct SanddServer SanddServer; -typedef struct SanddSession SanddSession; - -SanddServer* sandd_server_start(const char* bind_addr, const char* public_key_pem, - const char* controller_id, const char* issuer, - const char* kid); -void sandd_server_free(SanddServer*); -int sandd_server_daemon_count(const SanddServer*); -char* sandd_server_stats_json(const SanddServer*); - -int sandd_exec(const SanddServer*, const char* daemon_id, const char* command, - uint64_t timeout_secs, char** out_json); - -SanddSession* sandd_session_open(const SanddServer*, const char* daemon_id, - uint16_t rows, uint16_t cols, const char* term); -int sandd_session_write(const SanddSession*, const uint8_t* data, size_t len); -int sandd_session_read(const SanddSession*, uint8_t* out, size_t cap, - uint64_t timeout_ms); -int sandd_session_resize(const SanddSession*, uint16_t rows, uint16_t cols); -void sandd_session_free(SanddSession*); -char* sandd_session_id(const SanddSession*); - -const char* sandd_last_error(void); -void sandd_string_free(char*); -*/ -import "C" - -import ( - "encoding/json" - "errors" - "fmt" - "runtime" - "sync" - "time" - "unsafe" -) - -// Return codes from the C ABI. Mirrors the SANDD_* constants in ffi.rs; a change -// there without a change here is a silent misclassification, so they are asserted -// against the header's documented values in the tests. -const ( - rcOK = 0 - rcErr = -1 - rcNoDaemon = -2 - rcTimeout = -3 - rcClosed = -4 -) - -// ReadBufSize is the smallest buffer Session.Read may be given: the underlying channel -// yields whole chunks, so a shorter buffer DISCARDS the tail of one rather than resuming -// it on the next call (SANDD_READ_BUF_MIN in ffi.rs). -// -// EXPORTED because it is a correctness floor, not a tuning knob, and the caller allocates -// the buffer. A host that hard-codes 64KiB of its own looks correct and starts silently -// truncating output the day this floor rises — the exact drift this binding exists to -// prevent, so the number must be referenced, never copied. -const ReadBufSize = 64 * 1024 - -var ( - // ErrClosed is returned by every method on a Server or Session that has been - // closed. It exists so a late caller gets an error instead of dereferencing freed - // memory, which is the failure mode this package is built to prevent. - ErrClosed = errors.New("sandd: handle is closed") - - // ErrNoDaemon means the daemon id is not in the registry: it never connected, or it - // was reaped or disconnected. Distinct from a generic failure so callers can map it - // to a NotFound rather than an internal error. - ErrNoDaemon = errors.New("sandd: daemon not connected") - - // ErrSessionClosed means the session ended — the daemon exited, disconnected, or - // closed the PTY. Terminal: no further reads will succeed. - ErrSessionClosed = errors.New("sandd: session closed") -) - -// Config configures the embedded controller. -type Config struct { - // Bind is the listen address for daemon dial-ins, e.g. "0.0.0.0:8765". - // - // Every connected daemon costs this process roughly 15-50KB (tokio task, framing - // buffers, registry entry, kernel socket buffers), so the manager's memory limit - // must cover the expected fleet. Exceeding it presents as an OOMKill, which reads - // like a crash loop rather than like capacity — the reason that number is stated - // here rather than left to be discovered. - Bind string - - // PublicKeyPEM and ControllerID enable authentication and MUST be set together. - // Empty means auth is DISABLED, which admits any caller that speaks the protocol - // and is for tests only. Half-configured is rejected by the Rust side rather than - // silently downgraded. - // - // ControllerID is the ONLY audience admitted, and must equal the aud the manager - // mints (see pkg/sandd.Signer). - PublicKeyPEM string - ControllerID string - - // Issuer and KID must match what the minter puts in the token. Issuer defaults to - // "nebula"; an empty KID accepts any key id, which is what lets a rotation present - // old and new keys. - Issuer string - KID string -} - -// Server is the embedded SandD controller. -type Server struct { - // mu guards ptr and sessions. Held only around pointer bookkeeping, never across a - // blocking C call: sandd_exec parks for up to its timeout, and holding mu there - // would serialize every exec in the process behind one slow command. - mu sync.Mutex - ptr *C.SanddServer - sessions map[*Session]struct{} - - // inflight counts calls that have copied ptr and are executing in C right now. - // - // Required because Exec releases mu for the duration of its call: nil'ing ptr is - // then NOT enough to make freeing safe, since a blocked call still holds its own - // copy. sandd_server_free drops the tokio Runtime that sandd_exec is parked on, so - // freeing underneath one is a use-after-free of a running executor, not merely a - // stale handle. Close waits for this to drain before freeing. - // - // Add is only ever called under mu with ptr non-nil, and Close nils ptr under mu - // before it Waits — so no Add can race a Wait. - inflight sync.WaitGroup -} - -// acquire hands out the raw handle and registers an in-flight C call, or fails if the -// server is closed. Every acquire MUST be paired with a release, hence the defer at each -// call site: a leaked count wedges Close forever. -func (s *Server) acquire() (*C.SanddServer, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrClosed - } - s.inflight.Add(1) - return s.ptr, nil -} - -func (s *Server) release() { s.inflight.Done() } - -// Start launches the controller. The returned Server must be Closed to release the -// listening socket and every daemon connection. -func Start(cfg Config) (*Server, error) { - if cfg.Bind == "" { - return nil, errors.New("sandd: Config.Bind is required") - } - // Caught here rather than at the boundary so the error names the Go field. - if (cfg.PublicKeyPEM == "") != (cfg.ControllerID == "") { - return nil, errors.New( - "sandd: PublicKeyPEM and ControllerID must be set together " + - "(both empty disables authentication)") - } - - bind := C.CString(cfg.Bind) - defer C.free(unsafe.Pointer(bind)) - - // NULL, not "", for the auth material: the C side treats both-NULL as "auth off" - // and an empty string as a misconfiguration, so an empty CString would be rejected - // rather than disabling auth. - var key, id, iss, kid *C.char - if cfg.PublicKeyPEM != "" { - key = C.CString(cfg.PublicKeyPEM) - defer C.free(unsafe.Pointer(key)) - id = C.CString(cfg.ControllerID) - defer C.free(unsafe.Pointer(id)) - - issuer := cfg.Issuer - if issuer == "" { - issuer = "nebula" - } - iss = C.CString(issuer) - defer C.free(unsafe.Pointer(iss)) - kid = C.CString(cfg.KID) - defer C.free(unsafe.Pointer(kid)) - } - - ptr := C.sandd_server_start(bind, key, id, iss, kid) - if ptr == nil { - return nil, fmt.Errorf("sandd: failed to start controller: %s", lastError()) - } - return &Server{ptr: ptr, sessions: make(map[*Session]struct{})}, nil -} - -// Close stops the controller, dropping every daemon socket it holds. -// -// Sessions are closed FIRST and their handles freed before the server's: a Session -// borrows the server's tokio runtime, so freeing the server while one is open would -// leave a dangling handle. Close is idempotent. -// -// BLOCKS until every in-flight call returns. ptr is nil'd first, so callers arriving -// after this point get ErrClosed and cannot join the set being waited on; the ones -// already parked in C are waited out because sandd_server_free drops the runtime they -// are running on. An Exec with a long timeout therefore delays Close by up to that -// timeout — bounded, and the alternative is freeing a live executor. -func (s *Server) Close() error { - s.mu.Lock() - if s.ptr == nil { - s.mu.Unlock() - return nil - } - open := make([]*Session, 0, len(s.sessions)) - for sess := range s.sessions { - open = append(open, sess) - } - ptr := s.ptr - s.ptr = nil - s.sessions = nil - s.mu.Unlock() - - // Outside s.mu: Session.Close calls back into s.forget, which takes it. Each of - // these waits out its own parked reader, so sessions are fully quiescent before the - // server's runtime goes away. - for _, sess := range open { - _ = sess.Close() - } - - // After the sessions: a session's read parks on a handle to THIS runtime, so - // draining them first is what makes waiting here sufficient. - s.inflight.Wait() - - C.sandd_server_free(ptr) - return nil -} - -// DaemonCount reports how many daemons are currently connected. -func (s *Server) DaemonCount() (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return 0, ErrClosed - } - n := C.sandd_server_daemon_count(s.ptr) - if n < 0 { - return 0, fmt.Errorf("sandd: %s", lastError()) - } - return int(n), nil -} - -// DaemonInfo describes one connected daemon. -type DaemonInfo struct { - Hostname string `json:"hostname"` - Platform string `json:"platform"` - Arch string `json:"arch"` - Version string `json:"version"` - Labels map[string]string `json:"labels"` - IsBusy bool `json:"is_busy"` - // ConnectedSecs is how long this daemon has been connected. - ConnectedSecs uint64 `json:"connected_secs"` - // SecondsSinceHeartbeat, compared against the controller's reap threshold, says how - // close this daemon is to being dropped from the registry. - SecondsSinceHeartbeat uint64 `json:"seconds_since_heartbeat"` -} - -// Stats is the registry snapshot, keyed by daemon id. -type Stats struct { - TotalDaemons int `json:"total_daemons"` - ByPlatform map[string]int `json:"by_platform"` - OldestConnectionSecs uint64 `json:"oldest_connection_secs"` - Daemons map[string]DaemonInfo `json:"daemons"` -} - -// Stats returns a snapshot of the registry. -func (s *Server) Stats() (*Stats, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrClosed - } - raw := C.sandd_server_stats_json(s.ptr) - if raw == nil { - return nil, fmt.Errorf("sandd: %s", lastError()) - } - defer C.sandd_string_free(raw) - - var out Stats - if err := json.Unmarshal([]byte(C.GoString(raw)), &out); err != nil { - return nil, fmt.Errorf("sandd: decode stats: %w", err) - } - return &out, nil -} - -// ExecResult is the outcome of a one-shot command. -type ExecResult struct { - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - ExitCode int `json:"exit_code"` - DurationMS uint64 `json:"duration_ms"` -} - -// Exec runs command on a daemon and blocks until it completes or timeout elapses. -// -// One-shot: the result is complete stdout/stderr after the fact. That backs -// `kubectl exec -- ls` but not an interactive shell; use OpenSession for a PTY. -// -// A timeout is NOT retryable. The command may have run — a timeout says only that no -// answer arrived — so retrying risks executing it twice. -func (s *Server) Exec(daemonID, command string, timeout time.Duration) (*ExecResult, error) { - // acquire, not a bare pointer copy: this call outlives its hold on s.mu, so it must - // keep Close from freeing the handle underneath it. - ptr, err := s.acquire() - if err != nil { - return nil, err - } - defer s.release() - - cid := C.CString(daemonID) - defer C.free(unsafe.Pointer(cid)) - ccmd := C.CString(command) - defer C.free(unsafe.Pointer(ccmd)) - - secs := uint64(timeout.Seconds()) - if secs == 0 { - secs = 1 // A zero timeout means "no time at all" to the C side, never "no limit". - } - - var out *C.char - // Blocks for up to `timeout` without holding s.mu, so concurrent execs to different - // daemons proceed in parallel. - rc := C.sandd_exec(ptr, cid, ccmd, C.uint64_t(secs), &out) - if rc != rcOK { - if rc == rcNoDaemon { - return nil, fmt.Errorf("%w: %s", ErrNoDaemon, daemonID) - } - return nil, fmt.Errorf("sandd: exec on %s: %s", daemonID, lastError()) - } - if out == nil { - return nil, errors.New("sandd: exec returned no result") - } - defer C.sandd_string_free(out) - - var res ExecResult - if err := json.Unmarshal([]byte(C.GoString(out)), &res); err != nil { - return nil, fmt.Errorf("sandd: decode exec result: %w", err) - } - return &res, nil -} - -// forget drops a session from the server's tracking set. Called by Session.Close. -func (s *Server) forget(sess *Session) { - s.mu.Lock() - defer s.mu.Unlock() - if s.sessions != nil { - delete(s.sessions, sess) - } -} - -// Session is one interactive PTY on a daemon. Safe for one reader and one writer -// concurrently, which is what a terminal relay needs. -type Session struct { - mu sync.Mutex - ptr *C.SanddSession - srv *Server - id string - buf []byte // Reused across Reads; guarded by readMu, not mu. - - // readMu serializes Read so two concurrent readers cannot share buf and interleave - // output. Separate from mu because Read must not hold mu while parked in C. - readMu sync.Mutex - - // inflight counts reads parked in C, for the same reason as Server.inflight: Read - // releases mu before blocking, so nil'ing ptr does not stop a parked call from - // holding its own copy. Close waits for it before freeing the handle. - inflight sync.WaitGroup - - // free releases the handle. nil means sandd_session_free — overridden only by - // stubSession, so a test can observe WHEN the free happens. - free func(*C.SanddSession) -} - -// acquire hands out the raw handle and registers an in-flight C call, or fails if the -// session is closed. Must be paired with a release. -func (s *Session) acquire() (*C.SanddSession, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrClosed - } - s.inflight.Add(1) - return s.ptr, nil -} - -func (s *Session) release() { s.inflight.Done() } - -// stubSession builds a Session whose handle is non-nil but never reaches the C free, so a -// test can assert the ORDER of "wait for parked reads, then free". -// -// It lives in this file, not the test, for two reasons: a real Session needs a connected -// daemon, which this dependency-free module cannot fake, and cgo is not permitted in -// _test.go files at all — so anything naming *C.SanddSession has to be here. The handle is -// a 1-byte malloc that is never dereferenced, freed by the stub itself. -func stubSession(onFree func()) *Session { - return &Session{ - ptr: (*C.SanddSession)(C.malloc(1)), - buf: make([]byte, ReadBufSize), - free: func(ptr *C.SanddSession) { - C.free(unsafe.Pointer(ptr)) - onFree() - }, - } -} - -// OpenSession starts an interactive session on a daemon with the given terminal -// geometry. term may be empty for "xterm-256color". -func (s *Server) OpenSession(daemonID string, rows, cols uint16, term string) (*Session, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return nil, ErrClosed - } - - cid := C.CString(daemonID) - defer C.free(unsafe.Pointer(cid)) - - var cterm *C.char - if term != "" { - cterm = C.CString(term) - defer C.free(unsafe.Pointer(cterm)) - } - - ptr := C.sandd_session_open(s.ptr, cid, C.uint16_t(rows), C.uint16_t(cols), cterm) - if ptr == nil { - // The C side does not distinguish "no such daemon" from other open failures by - // return value (it returns NULL either way), so the message is the only signal. - return nil, fmt.Errorf("sandd: open session on %s: %s", daemonID, lastError()) - } - - sess := &Session{ptr: ptr, srv: s, buf: make([]byte, ReadBufSize)} - if raw := C.sandd_session_id(ptr); raw != nil { - sess.id = C.GoString(raw) - C.sandd_string_free(raw) - } - s.sessions[sess] = struct{}{} - return sess, nil -} - -// ID is the controller-assigned session id, for correlating logs across the two halves. -func (s *Session) ID() string { return s.id } - -// Write sends stdin to the session. -func (s *Session) Write(p []byte) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return 0, ErrClosed - } - if len(p) == 0 { - return 0, nil - } - - rc := C.sandd_session_write(s.ptr, (*C.uint8_t)(&p[0]), C.size_t(len(p))) - // p is borrowed by the C call only for its duration — the Rust side copies into a - // protocol message before returning — but the Go GC must not move or collect it - // mid-call, which this guarantees. - runtime.KeepAlive(p) - - if rc != rcOK { - if rc == rcNoDaemon { - return 0, ErrNoDaemon - } - return 0, fmt.Errorf("sandd: session write: %s", lastError()) - } - return len(p), nil -} - -// Read copies session output into p, blocking at most timeout. -// -// Returns (0, nil) when the timeout elapses with no output. That is the NORMAL state of -// an idle terminal, not an error, so callers loop on it; only ErrSessionClosed is -// terminal. Reporting idleness as an error would make every quiet shell look broken. -// -// p should be at least ReadBufSize: the underlying channel yields whole chunks, and a -// short buffer discards the tail of one rather than resuming it on the next call. -func (s *Session) Read(p []byte, timeout time.Duration) (int, error) { - s.readMu.Lock() - defer s.readMu.Unlock() - - // acquire, not a bare pointer copy: this parks in C without holding s.mu, so it must - // keep Close from freeing the handle underneath it. - ptr, err := s.acquire() - if err != nil { - return 0, err - } - defer s.release() - - if len(p) == 0 { - return 0, nil - } - - ms := uint64(timeout.Milliseconds()) - if ms == 0 { - ms = 1 - } - - // Parks in C for up to `timeout` WITHOUT holding s.mu, so a concurrent Write or - // Resize is not blocked behind an idle read. - rc := C.sandd_session_read(ptr, (*C.uint8_t)(&p[0]), C.size_t(len(p)), C.uint64_t(ms)) - runtime.KeepAlive(p) - - switch { - case rc >= 0: - return int(rc), nil - case rc == rcTimeout: - return 0, nil - case rc == rcClosed: - return 0, ErrSessionClosed - default: - return 0, fmt.Errorf("sandd: session read: %s", lastError()) - } -} - -// Resize tells the daemon the terminal geometry changed. -func (s *Session) Resize(rows, cols uint16) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.ptr == nil { - return ErrClosed - } - rc := C.sandd_session_resize(s.ptr, C.uint16_t(rows), C.uint16_t(cols)) - if rc != rcOK { - if rc == rcNoDaemon { - return ErrNoDaemon - } - return fmt.Errorf("sandd: session resize: %s", lastError()) - } - return nil -} - -// Close ends the session and frees its handle. Idempotent. -// -// BLOCKS until a reader parked in Read returns, which takes up to that read's timeout. -// The parked call holds its own pointer copy, so nil'ing ptr does not protect it — -// waiting is what makes the free safe. Such a reader sees whatever its own call returned -// (ErrSessionClosed once the daemon drops the channel, or a timeout), and only a reader -// arriving AFTER this gets ErrClosed. -// -// Callers must therefore not hold a lock that a reader needs, and a Read timeout doubles -// as the worst-case Close latency — keep it short (Nebula's relay polls at 500ms and -// loops, rather than parking for the session's whole lifetime) rather than unbounded. -func (s *Session) Close() error { - s.mu.Lock() - if s.ptr == nil { - s.mu.Unlock() - return nil - } - ptr := s.ptr - s.ptr = nil - s.mu.Unlock() - - // Before the free, and outside s.mu so a parked read can finish: it is holding a copy - // of ptr and running on the server's runtime. - s.inflight.Wait() - - if s.srv != nil { - s.srv.forget(s) - } - if s.free != nil { - s.free(ptr) - } else { - C.sandd_session_free(ptr) - } - return nil -} - -// lastError reads the calling thread's error message from the C side. -// -// MUST be called on the same OS thread that saw the failure — the message is -// thread-local in Rust. cgo pins the calling goroutine to its OS thread for the duration -// of a C call, so a lastError() invoked immediately after a failed call in the same Go -// statement sequence is on the right thread. Any goroutine switch in between and the -// message is lost, so every call site here fetches it directly after the failure with no -// intervening operation. -func lastError() string { - msg := C.sandd_last_error() - if msg == nil { - return "unknown error" - } - return C.GoString(msg) -} diff --git a/go/controller/controller_test.go b/go/controller/controller_test.go deleted file mode 100644 index d4041b6..0000000 --- a/go/controller/controller_test.go +++ /dev/null @@ -1,330 +0,0 @@ -/* -Copyright 2026 The InftyAI Team. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package controller - -import ( - "errors" - "strings" - "sync" - "testing" - "time" -) - -// Every test binds an EXPLICIT distinct port on loopback. The controller's listener is -// spawned and not awaited, so a bind collision surfaces as "no daemon ever connects" -// rather than as a Start error — sharing a port between tests would produce a passing -// test that measured nothing. -const ( - portMisconfig = "127.0.0.1:19101" - portLifecycle = "127.0.0.1:19102" - portNoDaemon = "127.0.0.1:19103" - portErrs = "127.0.0.1:19104" - portInflight = "127.0.0.1:19105" - portCloseRace = "127.0.0.1:19106" -) - -func TestStartRejectsHalfConfiguredAuth(t *testing.T) { - // The dangerous direction: a caller that MEANT to enable auth but supplied only one - // of the two fields must not get a controller that admits everyone. - cases := []struct { - name string - cfg Config - }{ - {"key without controller id", Config{Bind: portMisconfig, PublicKeyPEM: "pem"}}, - {"controller id without key", Config{Bind: portMisconfig, ControllerID: "sandd-abc"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - srv, err := Start(tc.cfg) - if err == nil { - srv.Close() - t.Fatal("expected an error, got a running controller with auth disabled") - } - if !strings.Contains(err.Error(), "must be set together") { - t.Errorf("error should explain the pairing requirement, got: %v", err) - } - }) - } -} - -func TestStartRequiresBind(t *testing.T) { - if _, err := Start(Config{}); err == nil { - t.Fatal("expected an error for an empty Bind") - } -} - -func TestServerLifecycle(t *testing.T) { - srv, err := Start(Config{Bind: portLifecycle}) - if err != nil { - t.Fatalf("Start: %v", err) - } - - n, err := srv.DaemonCount() - if err != nil { - t.Fatalf("DaemonCount: %v", err) - } - if n != 0 { - t.Errorf("DaemonCount = %d, want 0 with no daemon connected", n) - } - - stats, err := srv.Stats() - if err != nil { - t.Fatalf("Stats: %v", err) - } - if stats.TotalDaemons != 0 { - t.Errorf("TotalDaemons = %d, want 0", stats.TotalDaemons) - } - // Decoding must produce usable maps, not nil, or callers range over nil silently. - if stats.Daemons == nil || stats.ByPlatform == nil { - t.Error("Stats maps should be non-nil after decoding") - } - - if err := srv.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - // Idempotent: a double Close must not double-free. - if err := srv.Close(); err != nil { - t.Fatalf("second Close: %v", err) - } - - // The point of the wrapper: post-Close calls error instead of dereferencing freed - // memory. - if _, err := srv.DaemonCount(); !errors.Is(err, ErrClosed) { - t.Errorf("DaemonCount after Close = %v, want ErrClosed", err) - } - if _, err := srv.Stats(); !errors.Is(err, ErrClosed) { - t.Errorf("Stats after Close = %v, want ErrClosed", err) - } - if _, err := srv.Exec("d-1", "true", time.Second); !errors.Is(err, ErrClosed) { - t.Errorf("Exec after Close = %v, want ErrClosed", err) - } - if _, err := srv.OpenSession("d-1", 24, 80, ""); !errors.Is(err, ErrClosed) { - t.Errorf("OpenSession after Close = %v, want ErrClosed", err) - } -} - -func TestOperationsOnUnknownDaemon(t *testing.T) { - srv, err := Start(Config{Bind: portNoDaemon}) - if err != nil { - t.Fatalf("Start: %v", err) - } - defer srv.Close() - - // Exec distinguishes "not connected" by return code, so callers can map it to a - // NotFound rather than an internal error. - if _, err := srv.Exec("no-such-daemon", "true", time.Second); !errors.Is(err, ErrNoDaemon) { - t.Errorf("Exec on unknown daemon = %v, want ErrNoDaemon", err) - } - - // OpenSession cannot: the C side returns NULL for every failure, so only the message - // distinguishes them. Asserted so a future ABI change that adds a code is noticed. - _, err = srv.OpenSession("no-such-daemon", 24, 80, "") - if err == nil { - t.Fatal("OpenSession on an unknown daemon should fail") - } - if !strings.Contains(err.Error(), "not found") { - t.Errorf("error should say the daemon was not found, got: %v", err) - } -} - -// Close must not free the handle while a call that copied it is still in C. -// -// Exec releases s.mu before calling, so nil'ing ptr does not protect the parked call — -// and sandd_server_free drops the tokio runtime that sandd_exec is parked on, making this -// a use-after-free of a running executor rather than a stale-handle read. Asserted -// through the inflight counter directly: reproducing the free is undefined behaviour, -// which a test cannot observe reliably (it may well pass while corrupting memory). -// -// Run this under -race and with CGO_LDFLAGS pointing at a debug/ASan archive to get more -// than the counter check. -func TestCloseWaitsForInflightCalls(t *testing.T) { - srv, err := Start(Config{Bind: portInflight}) - if err != nil { - t.Fatalf("Start: %v", err) - } - - // Stand in for a call parked in C: acquire is exactly what Exec does before it - // releases the lock, and the count is what Close has to respect. - ptr, err := srv.acquire() - if err != nil { - t.Fatalf("acquire: %v", err) - } - if ptr == nil { - t.Fatal("acquire returned a nil handle on a live server") - } - - closed := make(chan error, 1) - go func() { closed <- srv.Close() }() - - // Close must still be blocked while the call is outstanding. A poll rather than a - // single sleep so the test does not depend on goroutine scheduling order. - select { - case <-closed: - t.Fatal("Close returned while a call was still in flight; the handle was freed underneath it") - case <-time.After(100 * time.Millisecond): - } - - // Releasing lets Close finish — this is the ordering that makes the free safe. - srv.release() - - select { - case err := <-closed: - if err != nil { - t.Fatalf("Close: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("Close did not return after the in-flight call finished (counter leak?)") - } - - // The waiting must not have cost idempotency or the post-Close contract. - if err := srv.Close(); err != nil { - t.Fatalf("second Close: %v", err) - } - if _, err := srv.Exec("d-1", "true", time.Second); !errors.Is(err, ErrClosed) { - t.Errorf("Exec after Close = %v, want ErrClosed", err) - } -} - -// Session.Close has the same duty as Server.Close: Read parks in C holding its own copy -// of the handle, so freeing without waiting is a use-after-free. -// -// Driven through stubSession (see controller.go) because a real Session needs a connected -// daemon, and the handle must be non-nil or Close short-circuits as already-closed and -// never reaches the wait. What is under test is the ordering between the wait and the free. -func TestSessionCloseWaitsForAParkedRead(t *testing.T) { - freed := make(chan struct{}) - sess := stubSession(func() { close(freed) }) - - // A read parked in sandd_session_read: it has left s.mu and holds its own pointer - // copy, which is precisely why nil'ing ptr is not enough. - sess.inflight.Add(1) - - closed := make(chan error, 1) - go func() { closed <- sess.Close() }() - - select { - case <-closed: - t.Fatal("Close returned while a read was parked; the handle was freed underneath it") - case <-freed: - t.Fatal("the handle was freed while a read was still parked on it") - case <-time.After(100 * time.Millisecond): - } - - sess.inflight.Done() - - select { - case err := <-closed: - if err != nil { - t.Fatalf("Close: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("Close did not return after the read finished (counter leak?)") - } - - // The free must have happened — waiting must not have skipped it. - select { - case <-freed: - default: - t.Error("Close returned without freeing the handle") - } - - // Only a reader arriving AFTER Close sees ErrClosed; the parked one saw its own - // result. Asserted because the doc comment used to claim the opposite. - if _, err := sess.Read(make([]byte, ReadBufSize), time.Second); !errors.Is(err, ErrClosed) { - t.Errorf("Read after Close = %v, want ErrClosed", err) - } - if err := sess.Close(); err != nil { - t.Fatalf("second Close: %v", err) - } -} - -// A caller arriving after Close must be refused rather than joining the set Close is -// waiting on — otherwise Close could wait forever, or worse, return and then have a new -// call use the freed handle. -func TestAcquireAfterCloseIsRefused(t *testing.T) { - srv, err := Start(Config{Bind: portCloseRace}) - if err != nil { - t.Fatalf("Start: %v", err) - } - if err := srv.Close(); err != nil { - t.Fatalf("Close: %v", err) - } - - if _, err := srv.acquire(); !errors.Is(err, ErrClosed) { - t.Errorf("acquire after Close = %v, want ErrClosed", err) - } - - // Hammer it from several goroutines: every one must be refused, and none may leave - // the counter incremented (a leak would wedge any later Wait). - var wg sync.WaitGroup - for range 16 { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := srv.acquire(); !errors.Is(err, ErrClosed) { - t.Errorf("concurrent acquire after Close = %v, want ErrClosed", err) - } - }() - } - wg.Wait() - - // Wait returns immediately iff nothing leaked a count. - done := make(chan struct{}) - go func() { srv.inflight.Wait(); close(done) }() - select { - case <-done: - case <-time.After(2 * time.Second): - t.Fatal("inflight counter leaked: a refused acquire incremented it") - } -} - -// The error message is THREAD-LOCAL in Rust. cgo pins a goroutine to its OS thread only -// for the duration of a call, so concurrent failures on different goroutines must not -// bleed each other's messages — this asserts each caller sees its own. -func TestConcurrentErrorsDoNotCrossThreads(t *testing.T) { - srv, err := Start(Config{Bind: portErrs}) - if err != nil { - t.Fatalf("Start: %v", err) - } - defer srv.Close() - - const goroutines = 16 - var wg sync.WaitGroup - errs := make([]error, goroutines) - for i := range goroutines { - wg.Add(1) - go func(i int) { - defer wg.Done() - // Each goroutine asks about a DISTINCT daemon id, so a message from another - // goroutine is detectable by its content. - id := "daemon-" + string(rune('a'+i)) - _, errs[i] = srv.Exec(id, "true", time.Second) - }(i) - } - wg.Wait() - - for i, err := range errs { - if !errors.Is(err, ErrNoDaemon) { - t.Errorf("goroutine %d: got %v, want ErrNoDaemon", i, err) - continue - } - want := "daemon-" + string(rune('a'+i)) - if !strings.Contains(err.Error(), want) { - t.Errorf("goroutine %d: error names the wrong daemon (%v), want %q", i, err, want) - } - } -} diff --git a/go/go.mod b/go/go.mod deleted file mode 100644 index 9533587..0000000 --- a/go/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -// The Go binding for the SandD controller. -// -// Lives HERE rather than in a consumer: it wraps this repo's C ABI (server/src/ffi.rs), -// so an ABI change and its binding move in one commit and break one build. A copy in -// Nebula would drift silently — the linker only catches a missing symbol, never a -// changed meaning. -module github.com/InftyAI/SandD/go - -go 1.24 diff --git a/hack/docker/Dockerfile.controller b/hack/docker/Dockerfile.controller deleted file mode 100644 index 8fd9476..0000000 --- a/hack/docker/Dockerfile.controller +++ /dev/null @@ -1,102 +0,0 @@ -# The sandd-controller image — what Nebula runs, one Deployment per workload -# (DefaultSandDControllerImage = inftyai/sandd-controller:latest). -# -# Build: docker build -f hack/docker/Dockerfile.controller -t inftyai/sandd-controller:latest . -# Run: docker run -p 8765:8765 inftyai/sandd-controller:latest --help -# -# WHY NOT Dockerfile.server-tunnel: that image is python:3.11-slim plus Rust plus -# Tailscale, and launches the controller as `python -c "from sandd import Server"`. -# Under Nebula none of that is used — no Python drives the controller (daemons dial -# IN, consumers reach it over HTTP) and there is no mesh (the daemon dials the -# ClusterIP Service directly, see the drop-mesh dial-out design). At one controller -# per workload, the interpreter and mesh client would be pure per-workload overhead. -# -# TWO STAGES so the toolchain never ships: the builder carries rustup and the whole -# registry cache, the result is one static binary on a distroless base (~50MB total). -# -# The release pipeline (.github/workflows/release.yaml) builds the same binary for the -# same musl targets and attaches it to the GitHub release, so a tagged image and the -# published binary are the same artifact built the same way. - -# Pinned, not `rust:1-slim`: the toolchain is part of what makes this image -# reproducible. 1.90 rather than something older because dependencies in Cargo.lock -# require edition2024 (stabilized in 1.85) — a 1.83 builder fails to parse them. -FROM rust:1.90-slim-bookworm AS builder - -# musl, so the binary is fully STATIC and the runtime stage needs no libc at all. The -# daemon ships the same way (see release.yaml), so both halves of SandD have the same -# "runs on anything" property. -# -# TARGETARCH is supplied by buildx; the arch is resolved from it rather than -# hardcoded, so one Dockerfile serves both legs of the multi-arch manifest. -ARG TARGETARCH -RUN case "$TARGETARCH" in \ - amd64) echo x86_64-unknown-linux-musl > /target.txt ;; \ - arm64) echo aarch64-unknown-linux-musl > /target.txt ;; \ - *) echo "unsupported TARGETARCH: $TARGETARCH" >&2; exit 1 ;; \ - esac \ - && rustup target add "$(cat /target.txt)" - -WORKDIR /build - -# The whole workspace, because cargo parses EVERY member's manifest even when -# building one package — and `sandd/Cargo.toml` declares a [[bench]] plus two -# [[example]]s whose paths point outside its own directory (`../examples/*.rs`), so a -# manifests-only copy does not even parse. -# -# The alternative (stub files for each of those targets) was tried and rejected: it -# makes this image depend on the exact set of targets the DAEMON declares, so adding -# an [[example]] over there breaks the controller build for a reason nobody would -# connect to it. Copying real sources is both smaller and honest. The cost is only -# that editing any Rust source invalidates the build layer; `docs/`, `examples/`, -# `python/tests/` and `target/` are already excluded by .dockerignore. -# -# Nothing here builds the daemon itself — `--bin sandd-controller` compiles only what -# the controller links, so portable-pty/sysinfo/blake3/criterion are never touched. -COPY . . - -# --locked so the image is reproducible: a build that silently updated a transitive -# dependency would not match the Cargo.lock the tests ran against. -# -# No --features python: the pyo3 layer is optional and stays OUT (see -# server/Cargo.toml). With extension-module compiled in, this binary could not link -# at all — the CPython symbols are deliberately left for an interpreter to supply. -# -# The `cdylib` warning here is expected and harmless: that crate type exists for the -# Python extension module, which no bin target links. -# -# The binary is copied to a fixed path so the runtime stage does not need to know the -# target triple. -RUN cargo build --release --locked --package sandbox-server --bin sandd-controller \ - --target "$(cat /target.txt)" \ - && cp "target/$(cat /target.txt)/release/sandd-controller" /sandd-controller \ - # Fail LOUDLY here rather than shipping a dynamically-linked binary that then - # dies on distroless/static with a confusing "no such file or directory" (which - # is really the missing interpreter, not the missing binary). - && if ! file /sandd-controller 2>/dev/null | grep -q .; then \ - apt-get update -qq && apt-get install -y -qq file; \ - fi \ - && file /sandd-controller \ - && file /sandd-controller | grep -qE "static-pie linked|statically linked" - -# distroless/STATIC, not /cc: a musl-static binary needs no libc, so there is nothing -# for a runtime image to provide beyond CA certs, /etc/passwd and tzdata. Also no -# shell and no package manager — nothing to exec if the process is ever compromised. -FROM gcr.io/distroless/static-debian12:nonroot - -LABEL maintainer="InftyAI " -LABEL description="Per-workload SandD controller: daemons dial in over WebSocket." -LABEL org.opencontainers.image.source="https://github.com/InftyAI/SandD" - -COPY --from=builder /sandd-controller /usr/local/bin/sandd-controller - -# Documents the port; Nebula's Deployment names it explicitly -# (nebulav1alpha1.SanddControllerPort) and does not rely on this. -EXPOSE 8765 - -USER nonroot:nonroot - -# ENTRYPOINT, not CMD, so `docker run --enable-auth` appends flags rather than -# replacing the command. Nebula passes configuration as env vars and overrides -# neither, so its Deployment needs no command/args at all. -ENTRYPOINT ["/usr/local/bin/sandd-controller"] diff --git a/pyproject.toml b/pyproject.toml index ad4759e..441b3b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ module-name = "sandd._core" python-source = "python" manifest-path = "server/Cargo.toml" # `python` compiles server/src/python.rs (the pyo3 layer) and pulls pyo3/pythonize -# in — both are OPTIONAL so the sandd-controller binary can build without CPython. +# in — both are OPTIONAL so a plain `cargo test` can build without CPython. # Without this feature the wheel would contain no `_core` module at all. features = ["python", "pyo3/extension-module"] include = [ diff --git a/server/Cargo.toml b/server/Cargo.toml index 18a179f..306d422 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,29 +9,7 @@ homepage = "https://github.com/InftyAI/SandD" [lib] name = "sandbox_server" -# staticlib alongside cdylib so a cgo host can link the FFI surface (src/ffi.rs) as an -# ARCHIVE rather than a shared object. That is what lets Nebula's manager stay a single -# self-contained binary on a `distroless/static` base — a .dylib/.so would have to be -# shipped beside it and found at runtime. cdylib stays for pyo3 (CPython dlopens it) and -# rlib for main.rs. -crate-type = ["cdylib", "staticlib", "rlib"] - -# The controller as a native binary — what Nebula runs (inftyai/sandd-controller). -# -# WHY A BINARY AND NOT THE PYTHON `Server(...)`: under Nebula the controller is -# infrastructure, one Deployment per workload, and it does nothing a Python caller -# drives — daemons dial IN and consumers reach it over HTTP. Launching it as -# `python -c "from sandd import Server; ..."` would pay for a CPython interpreter, -# the extension module and a second event-loop owner per workload, to host a -# process that never executes a line of Python. At one controller per workload that -# overhead multiplies. The binary is the same Rust server with the pyo3 layer left -# out: ~10x smaller image, no interpreter, and a real argv/env config surface. -# -# The Python bindings are unaffected — they are behind the `python` feature and -# maturin still builds them (see pyproject.toml [tool.maturin] features). -[[bin]] -name = "sandd-controller" -path = "src/main.rs" +crate-type = ["cdylib", "rlib"] [dependencies] sandd-protocol = { path = "../protocol" } @@ -58,22 +36,15 @@ dashmap = "6.1" parking_lot = "0.12" # Python bindings, OPTIONAL — pulled in only by the `python` feature (see -# [features] below). The controller binary must not link CPython, and with -# extension-module set it could not: that feature deliberately leaves the CPython -# symbols undefined for an interpreter to supply at dlopen time, so any plain -# `cargo build` of a bin target fails to link (loudly on macOS, subtly elsewhere). +# [features] below). With extension-module set, pyo3 deliberately leaves the CPython +# symbols undefined for an interpreter to supply at dlopen time, so a plain `cargo +# test` linking it would fail (loudly on macOS, subtly elsewhere). pyo3 = { version = "0.20", features = ["extension-module", "anyhow"], optional = true } pythonize = { version = "0.20", optional = true } # Base64 for protocol base64 = "0.22" -# CLI for the sandd-controller binary. Same version/features as the daemon's, so -# both sides of the project present one flag style. `env` is what lets every flag -# also be read from an env var (Nebula configures the controller through a -# Deployment env block, which cannot conveniently assemble an argv). -clap = { version = "4.5", features = ["derive", "env"] } - # Daemon-token verification (EdDSA/Ed25519 JWTs minted by the Nebula manager). # # use_pem (a default feature) is required: the public key arrives as a PKIX PEM string @@ -87,14 +58,9 @@ jsonwebtoken = { version = "11.0", features = ["rust_crypto"] } [features] # OFF by default so `cargo build`/`cargo test` work with no interpreter in sight — -# that is the binary's build, and it is also what CI and `make test` run. +# that is what CI and `make test` run. # # maturin turns it on (pyproject.toml [tool.maturin] features) when building the # wheel, which is the only build that wants the extension module. default = [] python = ["pyo3", "pythonize"] -# The C ABI (src/ffi.rs), for a host that is not CPython — Nebula's Go manager links the -# cdylib via cgo. Pulls in NO extra dependency: a C surface needs only std. So unlike -# `python`, this feature cannot fail to link for want of an interpreter, and -# `cargo build --features ffi` works anywhere the plain build does. -ffi = [] diff --git a/server/src/ffi.rs b/server/src/ffi.rs deleted file mode 100644 index 9a0abd7..0000000 --- a/server/src/ffi.rs +++ /dev/null @@ -1,590 +0,0 @@ -// Allow the non-snake-case/unsafe-heavy shapes a C ABI requires. -#![allow(clippy::missing_safety_doc)] - -//! A C ABI over the controller, for hosts that are not CPython. -//! -//! THIRD CONSUMER, same crate: `main.rs` is the binary, `python.rs` is the pyo3 -//! extension, and this is the C surface a cgo host links against. All three drive the -//! SAME `SandboxServer` and `DaemonRegistry` — nothing here reimplements the protocol, -//! the registry or token verification, it only re-exposes them through pointers and -//! byte buffers instead of Python objects. -//! -//! Behind the `ffi` feature so a plain `cargo build` of the binary links no extra -//! symbols. Unlike the `python` feature this pulls in NO external dependency — a C ABI -//! needs only `std` — so enabling it cannot fail to link the way `extension-module` -//! deliberately does. -//! -//! ## Shape, and why it mirrors python.rs rather than improving on it -//! -//! `Session::read` (python.rs) is a BLOCKING PULL with a timeout, not a callback. That -//! choice is what makes a non-Python host cheap: the host never has to be called INTO, -//! so there are no function pointers crossing the boundary, no host runtime pinned per -//! callback, and no reentrancy to reason about. A Go caller runs `read` in one goroutine -//! and `write` from another, exactly as Python runs it on one thread and writes from -//! another. Keeping the same shape here is deliberate — a callback-style API would be -//! more "natural" C and much worse over cgo. -//! -//! ## Ownership rules the host must honour -//! -//! Every `*mut c_char` this module RETURNS is heap-allocated by Rust and must be freed -//! with `sandd_string_free`. Every `*const c_char` the host PASSES IN is borrowed for -//! the duration of the call and copied if retained. Handles (`SanddServer`, -//! `SanddSession`) are opaque and freed with their own `*_free`. Freeing a handle twice, -//! or using one after free, is undefined behaviour — the Go wrapper is responsible for -//! making that unrepresentable. -//! -//! Errors are reported as a NEGATIVE return code with a message retrievable via -//! `sandd_last_error`, which is thread-local: a message set on one thread is invisible to -//! another, so the host must fetch it on the same thread that saw the failure. - -use std::cell::RefCell; -use std::ffi::{c_char, c_int, CStr, CString}; -use std::ptr; -use std::sync::Arc; -use std::time::Duration; - -use sandd_protocol::Message; -use tokio::runtime::Runtime; -use tokio::sync::{mpsc, oneshot, Mutex}; -use uuid::Uuid; - -use crate::auth::TokenVerifier; -use crate::registry::DaemonRegistry; -use crate::server::SandboxServer; - -// ── error reporting ────────────────────────────────────────────────────────── - -thread_local! { - /// Last error on THIS thread. Thread-local rather than a global so two host - /// threads failing concurrently cannot overwrite each other's message — with a - /// shared slot the reported cause would depend on scheduling. - static LAST_ERROR: RefCell> = const { RefCell::new(None) }; -} - -fn set_error(msg: impl Into) { - // A NUL inside the message would truncate it at the boundary; replace rather than - // drop the error, since a mangled message still beats a silent failure. - let cleaned = msg.into().replace('\0', "?"); - LAST_ERROR.with(|slot| { - *slot.borrow_mut() = CString::new(cleaned).ok(); - }); -} - -/// The last error on the CALLING thread, or NULL if there is none. -/// -/// The returned pointer is owned by Rust and valid until the next failing call on this -/// thread. The host must copy it, not retain it, and must NOT free it. -#[no_mangle] -pub extern "C" fn sandd_last_error() -> *const c_char { - LAST_ERROR.with(|slot| match &*slot.borrow() { - Some(s) => s.as_ptr(), - None => ptr::null(), - }) -} - -/// Frees a string this library returned. NULL is accepted and ignored. -#[no_mangle] -pub unsafe extern "C" fn sandd_string_free(s: *mut c_char) { - if !s.is_null() { - drop(CString::from_raw(s)); - } -} - -/// Return codes. Negative is failure, and every failure sets `sandd_last_error`. -pub const SANDD_OK: c_int = 0; -pub const SANDD_ERR: c_int = -1; -/// The daemon id is not in the registry — it never connected, or was reaped/disconnected. -/// Distinct from SANDD_ERR because the host maps it to a 404-shaped outcome rather than -/// an internal error. -pub const SANDD_ERR_NO_DAEMON: c_int = -2; -/// `sandd_session_read` timed out with no data. NOT an error: an idle terminal produces -/// nothing for long stretches, so the host loops on this rather than tearing down. -pub const SANDD_TIMEOUT: c_int = -3; -/// The session's output channel closed — the daemon went away or the session ended. -pub const SANDD_CLOSED: c_int = -4; - -/// Smallest read buffer a host should pass to `sandd_session_read`. The channel yields -/// whole chunks and a short buffer silently discards the tail of one, so this is a -/// correctness floor, not a tuning knob. -pub const SANDD_READ_BUF_MIN: usize = 64 * 1024; - -/// Borrows a C string as `&str`, or sets an error and returns None. -unsafe fn as_str<'a>(p: *const c_char, what: &str) -> Option<&'a str> { - if p.is_null() { - set_error(format!("{what} is NULL")); - return None; - } - match CStr::from_ptr(p).to_str() { - Ok(s) => Some(s), - Err(_) => { - set_error(format!("{what} is not valid UTF-8")); - None - } - } -} - -/// Moves a Rust string out to the host as an owned `*mut c_char`. -fn out_string(s: String) -> *mut c_char { - match CString::new(s.replace('\0', "?")) { - Ok(c) => c.into_raw(), - Err(_) => ptr::null_mut(), - } -} - -// ── server handle ──────────────────────────────────────────────────────────── - -/// Opaque server handle. Owns the tokio runtime the WebSocket server runs on, so the -/// runtime outlives every session derived from it. -pub struct SanddServer { - runtime: Runtime, - registry: Arc, -} - -/// Starts a controller listening for daemon dial-ins on `bind_addr` (e.g. -/// "0.0.0.0:8765"). -/// -/// Passing NULL for `public_key_pem`/`controller_id` starts with authentication -/// DISABLED, which mirrors `SandboxServer::new` and must be used only in tests: an -/// unauthenticated controller admits any caller that speaks the protocol. With a key, -/// `controller_id` is the ONLY `aud` admitted, and `issuer`/`kid` must match the minter. -/// `kid` may be empty to accept any key id. -/// -/// Returns NULL on failure. -#[no_mangle] -pub unsafe extern "C" fn sandd_server_start( - bind_addr: *const c_char, - public_key_pem: *const c_char, - controller_id: *const c_char, - issuer: *const c_char, - kid: *const c_char, -) -> *mut SanddServer { - let Some(bind) = as_str(bind_addr, "bind_addr") else { - return ptr::null_mut(); - }; - - // Auth is on iff BOTH a key and an audience are supplied. Half-configured is - // rejected rather than silently downgraded: a caller that meant to enable auth and - // passed only one of the two would otherwise get a wide-open controller that looks - // healthy. - let auth = match ( - as_str(public_key_pem, "public_key_pem"), - as_str(controller_id, "controller_id"), - ) { - (Some(pem), Some(id)) => Some((pem, id)), - (None, None) => { - // as_str already set an error for each NULL; clear it, both-NULL is legal. - LAST_ERROR.with(|s| *s.borrow_mut() = None); - None - } - _ => { - set_error( - "public_key_pem and controller_id must be supplied together \ - (both NULL disables authentication)", - ); - return ptr::null_mut(); - } - }; - - let runtime = match Runtime::new() { - Ok(r) => r, - Err(e) => { - set_error(format!("failed to create tokio runtime: {e}")); - return ptr::null_mut(); - } - }; - - let server = match auth { - Some((pem, id)) => { - let iss = as_str(issuer, "issuer").unwrap_or("nebula"); - let k = as_str(kid, "kid").unwrap_or(""); - match TokenVerifier::new(pem, id, iss, k) { - Ok(v) => SandboxServer::with_auth(bind.to_string(), v), - Err(e) => { - set_error(format!("failed to build token verifier: {e}")); - return ptr::null_mut(); - } - } - } - None => SandboxServer::new(bind.to_string()), - }; - - let registry = server.registry(); - // The server task is spawned and deliberately not awaited: this call returns a - // handle, it does not block the host thread. A bind failure therefore surfaces in - // the server's own log rather than here — the host should treat "no daemon ever - // connects" as the symptom, the same way the binary does. - runtime.spawn(async move { - if let Err(e) = server.start().await { - eprintln!("sandd server error: {e}"); - } - }); - - Box::into_raw(Box::new(SanddServer { runtime, registry })) -} - -/// Stops the controller and frees the handle, dropping every daemon socket it holds. -/// NULL is accepted and ignored. Sessions derived from this server must be freed FIRST: -/// they hold a runtime handle that becomes dangling once the runtime is dropped. -#[no_mangle] -pub unsafe extern "C" fn sandd_server_free(srv: *mut SanddServer) { - if !srv.is_null() { - drop(Box::from_raw(srv)); - } -} - -/// Number of daemons currently registered. Returns a negative code on a NULL handle. -#[no_mangle] -pub unsafe extern "C" fn sandd_server_daemon_count(srv: *const SanddServer) -> c_int { - if srv.is_null() { - set_error("server handle is NULL"); - return SANDD_ERR; - } - (*srv).registry.count() as c_int -} - -/// Registry statistics as a JSON object, owned by the caller (free with -/// `sandd_string_free`). Returns NULL on failure. -/// -/// Built with `serde_json::json!` rather than by deriving `Serialize` on -/// `RegistryStats`: that type is shared with the binary and the pyo3 layer, and a derive -/// there would make this module's wire format a property of the registry rather than of -/// this boundary. Field names match the `/stats` HTTP route so a host can consume either -/// interchangeably. -#[no_mangle] -pub unsafe extern "C" fn sandd_server_stats_json(srv: *const SanddServer) -> *mut c_char { - if srv.is_null() { - set_error("server handle is NULL"); - return ptr::null_mut(); - } - let stats = (*srv).registry.get_stats(); - let daemons: serde_json::Map = stats - .daemons - .into_iter() - .map(|(id, d)| { - ( - id, - serde_json::json!({ - "hostname": d.hostname, - "platform": d.platform, - "arch": d.arch, - "version": d.version, - "labels": d.labels, - "is_busy": d.is_busy, - "connected_secs": d.connected_secs, - "seconds_since_heartbeat": d.seconds_since_heartbeat, - }), - ) - }) - .collect(); - - out_string( - serde_json::json!({ - "total_daemons": stats.total_daemons, - "by_platform": stats.by_platform, - "oldest_connection_secs": stats.oldest_connection_secs, - "daemons": daemons, - }) - .to_string(), - ) -} - -// ── one-shot exec ──────────────────────────────────────────────────────────── - -/// Runs `command` on `daemon_id` and blocks until it completes or `timeout_secs` -/// elapses. -/// -/// One-shot only: the result is complete stdout/stderr after the fact, so this backs -/// `kubectl exec -- ls` but NOT an interactive `-it` shell. Use the session API for that. -/// -/// On success returns SANDD_OK and writes a JSON object -/// `{stdout, stderr, exit_code, duration_ms}` to `*out_json`, which the caller frees with -/// `sandd_string_free`. `*out_json` is untouched on failure. -/// -/// A timeout is reported as SANDD_ERR, not SANDD_TIMEOUT: unlike an idle session read, -/// the command may have RUN — the caller cannot know — so this must not look retryable. -#[no_mangle] -pub unsafe extern "C" fn sandd_exec( - srv: *const SanddServer, - daemon_id: *const c_char, - command: *const c_char, - timeout_secs: u64, - out_json: *mut *mut c_char, -) -> c_int { - if srv.is_null() || out_json.is_null() { - set_error("server handle or out_json is NULL"); - return SANDD_ERR; - } - let Some(id) = as_str(daemon_id, "daemon_id") else { - return SANDD_ERR; - }; - let Some(cmd) = as_str(command, "command") else { - return SANDD_ERR; - }; - - let srv = &*srv; - let Some(conn) = srv.registry.get(id) else { - set_error(format!("daemon {id} not found")); - return SANDD_ERR_NO_DAEMON; - }; - - let request_id = Uuid::new_v4().to_string(); - let (tx, rx) = oneshot::channel(); - // Registered BEFORE the send, so a daemon that answers immediately cannot have its - // response arrive with no channel waiting for it. - conn.register_request(request_id.clone(), tx); - - if let Err(e) = conn.send_message(Message::ExecuteCommand { - request_id, - command: cmd.to_string(), - timeout_secs, - env: Default::default(), - cwd: None, - }) { - set_error(format!("failed to send command: {e}")); - return SANDD_ERR; - } - - srv.runtime.block_on(async { - match tokio::time::timeout(Duration::from_secs(timeout_secs), rx).await { - Ok(Ok(Message::CommandOutput { - stdout, - stderr, - exit_code, - duration_ms, - .. - })) => { - *out_json = out_string( - serde_json::json!({ - "stdout": stdout, - "stderr": stderr, - "exit_code": exit_code, - "duration_ms": duration_ms, - }) - .to_string(), - ); - SANDD_OK - } - Ok(Ok(Message::CommandError { error, .. })) => { - set_error(format!("command error: {error}")); - SANDD_ERR - } - Ok(Ok(_)) => { - set_error("unexpected response type for exec"); - SANDD_ERR - } - Ok(Err(_)) => { - set_error("command channel closed (daemon disconnected)"); - SANDD_ERR - } - Err(_) => { - set_error("command execution timed out"); - SANDD_ERR - } - } - }) -} - -// ── interactive sessions ───────────────────────────────────────────────────── - -/// Opaque handle to one interactive PTY session. -/// -/// Holds a runtime HANDLE, not the runtime: the session borrows the server's runtime, so -/// the server must outlive every session opened against it. -pub struct SanddSession { - session_id: String, - daemon_id: String, - registry: Arc, - runtime: tokio::runtime::Handle, - output_rx: Arc>>>, -} - -/// Opens an interactive session on `daemon_id` with the given terminal geometry. -/// `term` may be NULL for "xterm-256color". Returns NULL on failure. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_open( - srv: *const SanddServer, - daemon_id: *const c_char, - rows: u16, - cols: u16, - term: *const c_char, -) -> *mut SanddSession { - if srv.is_null() { - set_error("server handle is NULL"); - return ptr::null_mut(); - } - let Some(id) = as_str(daemon_id, "daemon_id") else { - return ptr::null_mut(); - }; - let term = if term.is_null() { - "xterm-256color" - } else { - match as_str(term, "term") { - Some(t) => t, - None => return ptr::null_mut(), - } - }; - - let srv = &*srv; - let Some(conn) = srv.registry.get(id) else { - set_error(format!("daemon {id} not found")); - return ptr::null_mut(); - }; - - let session_id = Uuid::new_v4().to_string(); - let (tx, rx) = mpsc::unbounded_channel(); - // Registered before the send, for the same reason as exec: output can arrive - // before this function returns. - conn.register_session(session_id.clone(), tx); - - if let Err(e) = conn.send_message(Message::NewSession { - session_id: session_id.clone(), - rows, - cols, - term: term.to_string(), - }) { - conn.close_session(&session_id); - set_error(format!("failed to start session: {e}")); - return ptr::null_mut(); - } - - Box::into_raw(Box::new(SanddSession { - session_id, - daemon_id: id.to_string(), - registry: srv.registry.clone(), - runtime: srv.runtime.handle().clone(), - output_rx: Arc::new(Mutex::new(rx)), - })) -} - -/// Writes `len` bytes of stdin to the session. Returns SANDD_OK or a negative code. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_write( - sess: *const SanddSession, - data: *const u8, - len: usize, -) -> c_int { - if sess.is_null() || (data.is_null() && len > 0) { - set_error("session handle or data is NULL"); - return SANDD_ERR; - } - let sess = &*sess; - let Some(conn) = sess.registry.get(&sess.daemon_id) else { - set_error("daemon disconnected"); - return SANDD_ERR_NO_DAEMON; - }; - let buf = if len == 0 { - Vec::new() - } else { - std::slice::from_raw_parts(data, len).to_vec() - }; - match conn.send_message(Message::SessionInput { - session_id: sess.session_id.clone(), - data: buf, - }) { - Ok(()) => SANDD_OK, - Err(e) => { - set_error(format!("failed to write to session: {e}")); - SANDD_ERR - } - } -} - -/// Reads up to `cap` bytes of session output into `out`, blocking at most -/// `timeout_ms`. -/// -/// Returns the byte count (>= 0) on success, SANDD_TIMEOUT if nothing arrived, or -/// SANDD_CLOSED once the session has ended. SANDD_TIMEOUT is EXPECTED and not an error — -/// an idle terminal produces nothing for long stretches — so a host loop should keep -/// polling on it and tear down only on SANDD_CLOSED. -/// -/// This is a blocking PULL by design: the host is never called into, so no function -/// pointer crosses the boundary and no host thread is pinned. Reading from one host -/// thread while writing from another is safe. -/// -/// Output longer than `cap` is truncated and the remainder DISCARDED — the channel -/// yields whole chunks, so a short buffer loses the tail of one rather than resuming it on -/// the next call. Hosts should pass at least `SANDD_READ_BUF_MIN`. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_read( - sess: *const SanddSession, - out: *mut u8, - cap: usize, - timeout_ms: u64, -) -> c_int { - if sess.is_null() || out.is_null() { - set_error("session handle or out buffer is NULL"); - return SANDD_ERR; - } - let sess = &*sess; - sess.runtime.block_on(async { - let mut rx = sess.output_rx.lock().await; - match tokio::time::timeout(Duration::from_millis(timeout_ms), rx.recv()).await { - Ok(Some(data)) => { - let n = data.len().min(cap); - ptr::copy_nonoverlapping(data.as_ptr(), out, n); - n as c_int - } - Ok(None) => SANDD_CLOSED, - Err(_) => SANDD_TIMEOUT, - } - }) -} - -/// Tells the daemon the terminal was resized. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_resize( - sess: *const SanddSession, - rows: u16, - cols: u16, -) -> c_int { - if sess.is_null() { - set_error("session handle is NULL"); - return SANDD_ERR; - } - let sess = &*sess; - let Some(conn) = sess.registry.get(&sess.daemon_id) else { - set_error("daemon disconnected"); - return SANDD_ERR_NO_DAEMON; - }; - match conn.send_message(Message::SessionResize { - session_id: sess.session_id.clone(), - rows, - cols, - }) { - Ok(()) => SANDD_OK, - Err(e) => { - set_error(format!("failed to resize session: {e}")); - SANDD_ERR - } - } -} - -/// Closes the session and frees the handle. NULL is accepted and ignored. -/// -/// Best-effort on the wire: a daemon that has already disconnected cannot be told, and -/// that is not an error — the local state is released either way, so this cannot leak on -/// the path that matters. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_free(sess: *mut SanddSession) { - if sess.is_null() { - return; - } - let sess = Box::from_raw(sess); - if let Some(conn) = sess.registry.get(&sess.daemon_id) { - let _ = conn.send_message(Message::SessionClose { - session_id: sess.session_id.clone(), - }); - conn.close_session(&sess.session_id); - } -} - -/// The session id, owned by the caller (free with `sandd_string_free`). Useful for -/// correlating host-side logs with the controller's. -#[no_mangle] -pub unsafe extern "C" fn sandd_session_id(sess: *const SanddSession) -> *mut c_char { - if sess.is_null() { - set_error("session handle is NULL"); - return ptr::null_mut(); - } - out_string((*sess).session_id.clone()) -} diff --git a/server/src/lib.rs b/server/src/lib.rs index 22aa2ea..285b637 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -5,20 +5,8 @@ //! The SandD controller: the registry of connected daemons, the WebSocket server //! they dial into, and the token verification that admits them. //! -//! THREE CONSUMERS, one crate: -//! - `src/main.rs` — the `sandd-controller` binary, which is what Nebula runs -//! (one Deployment per workload). It needs nothing but the modules below. -//! - `src/python.rs` — the pyo3 bindings maturin builds into `sandd._core`, for -//! driving a controller from Python. Behind the `python` feature so the binary -//! does not link CPython; see that module's docs. -//! - `src/ffi.rs` — a C ABI for hosts that are not CPython (Nebula's Go manager links -//! it via cgo). Behind the `ffi` feature. Same registry, same protocol, same token -//! verification as the other two: it re-exposes them, it does not reimplement them. -//! -//! The modules are `pub` rather than private because `main.rs` is a SEPARATE crate -//! that reaches them through this library — a private `mod` would be invisible to -//! it, and giving the binary its own module tree would compile the server twice and -//! let the two copies drift. +//! Driven from Python: `src/python.rs` holds the pyo3 bindings maturin builds into +//! `sandd._core`, behind the `python` feature so `cargo test` needs no interpreter. pub mod auth; pub mod registry; @@ -26,8 +14,3 @@ pub mod server; #[cfg(feature = "python")] mod python; - -// `pub` unlike `python`: the exported symbols must reach the cdylib's symbol table for a -// C host to link them, which a private module would not do. -#[cfg(feature = "ffi")] -pub mod ffi; diff --git a/server/src/main.rs b/server/src/main.rs deleted file mode 100644 index a943510..0000000 --- a/server/src/main.rs +++ /dev/null @@ -1,623 +0,0 @@ -//! `sandd-controller` — the per-workload SandD controller Nebula runs. -//! -//! One of these exists per workload (Nebula's PodPlacement reconciler creates a -//! Deployment + ClusterIP Service named `sandd-`, owned by the -//! workload so it is garbage-collected with it). The workload's daemons dial IN to -//! `/ws`, prove who they are with a token the Nebula manager minted, and are held in -//! the registry so exec/logs traffic can be routed to them. -//! -//! FLAGS, EACH ALSO READABLE FROM AN ENV VAR. `--enable-auth` is the switch that -//! turns daemon authentication on; the verification material has a flag too. Both -//! surfaces exist because both callers are real: a person running this by hand wants -//! `--help` and flags, while Nebula configures it through a Deployment env block -//! (assembling an argv in the reconciler would make every value a string splice). -//! clap's `env` gives one definition per setting, so the two cannot drift, and -//! `--help` documents the env var beside each flag. -//! -//! The env names are a CONTRACT with Nebula's `sanddControllerEnv` -//! (internal/controller/pod_placement_helpers.go) — renaming one silently stops -//! daemons from authenticating, so the two lists must move together. -//! -//! FAILURE POSTURE: a misconfiguration is FATAL at startup, never degraded into -//! "auth disabled". A controller that admits every caller looks identical to a -//! healthy one — it passes probes, it serves /stats, it registers daemons — so the -//! mistake surfaces only as a security incident. Exiting non-zero makes it a -//! CrashLoopBackOff an operator sees immediately. - -use clap::Parser; -use sandbox_server::auth::TokenVerifier; -use sandbox_server::server::SandboxServer; -use std::net::IpAddr; -use tracing::info; - -/// The `iss` required when the issuer is not configured. Matches the Nebula -/// manager's own default (pkg/sandd), so the common deployment configures neither -/// side. Both sides defaulting to the same literal is what keeps that safe. -const DEFAULT_ISSUER: &str = "nebula"; - -/// 0.0.0.0, not 127.0.0.1: the daemons dialing in are on other machines entirely -/// (GPU instances at a neocloud provider), reaching this through a Service. A -/// loopback default would produce a controller that starts cleanly and is -/// unreachable by every one of its clients. -const DEFAULT_HOST: &str = "0.0.0.0"; - -/// Matches `nebulav1alpha1.SanddControllerPort` and the daemon's dial-out URL. Kept -/// as a default rather than a required setting so a hand-run controller needs no -/// configuration at all. -const DEFAULT_PORT: u16 = 8765; - -/// The controller's command line. Every flag carries its env fallback, so -/// `--enable-auth` and `SANDD_ENABLE_AUTH=true` are the same switch. -#[derive(Parser, Debug, Default)] -#[command( - name = "sandd-controller", - version, - about = "Per-workload SandD controller: daemons dial in over WebSocket and are \ - admitted by token." -)] -struct Args { - /// Address to listen on. 0.0.0.0 because daemons dial in from other machines. - #[arg(long, env = "SANDD_HOST", default_value = DEFAULT_HOST)] - host: String, - - /// Port for the daemon WebSocket (`/ws`), /stats and /health. - #[arg(long, env = "SANDD_PORT", default_value_t = DEFAULT_PORT)] - port: u16, - - /// Require a valid daemon token on every connection. - // - // Doc comments here are --help TEXT, so the rationale lives in plain comments - // below rather than being read out to an operator debugging a Deployment. - // - // num_args(0..=1) + default_missing_value rather than a plain SetTrue flag: a bare - // `--enable-auth` must work for a human, AND the env var must honour - // SANDD_ENABLE_AUTH=false. A SetTrue flag ignores the env VALUE entirely — merely - // setting the var would enable auth, so `=false` would turn it ON. Of the two ways - // to be wrong, that is the dangerous one. - #[arg( - long, - env = "SANDD_ENABLE_AUTH", - num_args = 0..=1, - default_missing_value = "true", - default_value = "false", - value_parser = parse_bool, - )] - enable_auth: bool, - - /// This controller's id, and the only `aud` it admits. Required with - /// --enable-auth. - #[arg(long, env = "SANDD_CONTROLLER_ID")] - controller_id: Option, - - /// PKIX PEM public key daemon tokens are verified against. Required with - /// --enable-auth. - // - // allow_hyphen_values because a PEM STARTS with `-----BEGIN PUBLIC KEY-----`: - // without it clap reads the value as an unknown flag and the flag form is simply - // unusable. Nebula's env path never hits this, which is exactly why it would have - // gone unnoticed until someone ran the binary by hand. - // - // Passing a key on argv is acceptable even though /proc//cmdline is - // world-readable, because this half is PUBLIC. The daemon's TOKEN is the opposite - // case and is deliberately env-only with no flag at all (sandd/src/main.rs). - #[arg(long, env = "SANDD_SIGNING_PUBLIC_KEY", allow_hyphen_values = true)] - signing_public_key: Option, - - /// The `kid` that public key answers to. Required with --enable-auth. - #[arg(long, env = "SANDD_SIGNING_KID")] - signing_kid: Option, - - /// The `iss` a token must carry [default: nebula]. - #[arg(long, env = "SANDD_TOKEN_ISSUER")] - token_issuer: Option, -} - -/// Parses a boolean setting, accepting the spellings operators actually write. -/// -/// An UNRECOGNIZED value is an ERROR, not a falsy default. `--enable-auth=yes` is -/// fine, but `SANDD_ENABLE_AUTH=enabled` silently meaning "off" is precisely the -/// belief-vs-reality gap this module exists to prevent. -fn parse_bool(raw: &str) -> Result { - match raw.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - // Empty counts as off: Kubernetes readily produces `NAME=""` (an env var with - // no value, a configMapKeyRef to an empty key), and clap passes it through. - "0" | "false" | "no" | "off" | "" => Ok(false), - other => Err(format!( - "expected one of true/false/1/0/yes/no/on/off, got {:?}", - other - )), - } -} - -/// What the process needs to start. Resolved from the environment in one place so -/// the rules are testable without a running server. -#[derive(Debug, PartialEq, Eq)] -struct Config { - bind_addr: String, - /// None means authentication is OFF. Some(..) carries everything a verifier - /// needs, all of it already validated as present. - auth: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct AuthConfig { - controller_id: String, - public_key_pem: String, - kid: String, - issuer: String, -} - -/// Resolves the parsed command line into what the process needs to start. -/// -/// Separate from `Args` so the RULES (what is required with auth, what a valid port -/// is) are testable by building an Args value directly — no process env to mutate, -/// no argv to fake. `std::env::set_var` is global state and these tests run -/// concurrently in one process, so an env-mutating test would be a flaky test. -fn resolve(args: Args) -> Result { - // Validate the host as an IP here rather than letting `bind` fail later: at bind - // time the error is "invalid socket address" with no hint what produced it. - let host = trimmed(&args.host).unwrap_or_else(|| DEFAULT_HOST.to_string()); - host.parse::() - .map_err(|_| format!("--host is not a valid IP address: {:?}", host))?; - - if args.port == 0 { - // Port 0 means "any free port" to the OS. That binds SUCCESSFULLY and then - // nobody can reach it, because the daemons' URLs name a fixed port. - return Err("--port must not be 0".to_string()); - } - - // DEFAULTS TO OFF, a compatibility choice rather than a security preference: this - // binary also serves the standalone/local-dev and e2e stacks, where no manager - // exists to mint tokens, so defaulting to on would break all of them at once. - // Nebula passes the switch explicitly (it is the same gate that turns on minting - // in the manager), so the deployment that needs auth gets it. - let auth = if args.enable_auth { - Some(resolve_auth(&args)?) - } else { - None - }; - - Ok(Config { - bind_addr: format!("{}:{}", host, args.port), - auth, - }) -} - -/// Collects the verification material, requiring everything that has no safe default. -/// -/// The kid is required WITH auth even though `TokenVerifier` tolerates an empty one -/// (it then accepts any kid): tolerating it here would leave a controller unable to -/// tell a rotation mismatch from a forgery, and Nebula always sends one. -fn resolve_auth(args: &Args) -> Result { - // Names the FLAG and its env var, because the two callers read different ones: a - // person sees --controller-id, an operator debugging a CrashLoopBackOff sees the - // Deployment's env block. - let required = |value: &Option, flag: &str, env: &str| -> Result { - value - .as_deref() - .and_then(trimmed) - .ok_or_else(|| format!("--{} ({}) is required with --enable-auth", flag, env)) - }; - - Ok(AuthConfig { - controller_id: required(&args.controller_id, "controller-id", "SANDD_CONTROLLER_ID")?, - public_key_pem: required( - &args.signing_public_key, - "signing-public-key", - "SANDD_SIGNING_PUBLIC_KEY", - )?, - kid: required(&args.signing_kid, "signing-kid", "SANDD_SIGNING_KID")?, - // The only defaulted one — both sides default to the same literal. - issuer: args - .token_issuer - .as_deref() - .and_then(trimmed) - .unwrap_or_else(|| DEFAULT_ISSUER.to_string()), - }) -} - -/// The value unless it is blank, in which case None. -/// -/// Blank is treated as ABSENT throughout: Kubernetes readily produces `NAME=""` (an -/// env var with no value, a configMapKeyRef pointing at an empty key), and "" is not -/// a host, a controller id or a key. The PEM is the one value not re-trimmed after -/// this check — its trailing newline is part of what the parser wants. -fn trimmed(s: &str) -> Option { - let t = s.trim(); - if t.is_empty() { - None - } else { - Some(s.to_string()) - } -} - -/// Builds the server the config describes. -/// -/// The `Option` → `new`/`with_auth` split is the point where "auth on -/// but no usable key" stops being representable: `with_auth` takes an already-built -/// verifier, so a bad key fails HERE, before anything is listening. -fn build_server(config: Config) -> Result { - match config.auth { - Some(a) => { - let verifier = - TokenVerifier::new(&a.public_key_pem, &a.controller_id, &a.issuer, &a.kid)?; - Ok(SandboxServer::with_auth(config.bind_addr, verifier)) - } - None => Ok(SandboxServer::new(config.bind_addr)), - } -} - -#[tokio::main] -async fn main() { - // INFO by default; RUST_LOG overrides (e.g. RUST_LOG=debug). Logs are this - // process's only output — a controller is never attached to a terminal. - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - - // Parses argv AND the env fallbacks. clap exits 2 with a usage message on a bad - // flag, which is the right behaviour here too — a container that cannot parse its - // own configuration must not start. - let config = match resolve(Args::parse()) { - Ok(c) => c, - Err(e) => fatal(&e), - }; - info!( - "sandd-controller {} starting on {}", - env!("CARGO_PKG_VERSION"), - config.bind_addr - ); - - let server = match build_server(config) { - Ok(s) => s, - Err(e) => fatal(&e), - }; - - // start() logs which auth mode is in effect (info when enabled, warn when not). - if let Err(e) = server.start().await { - fatal(&format!("server stopped: {:#}", e)); - } -} - -/// Reports a startup failure and exits non-zero. -/// -/// Written to stderr as well as the log: a container that dies before the log -/// pipeline is scraped leaves `kubectl logs` as the only trace, and a bare -/// non-zero exit with no message is the worst thing to hand an operator. -fn fatal(msg: &str) -> ! { - tracing::error!("{}", msg); - eprintln!("sandd-controller: {}", msg); - std::process::exit(1); -} - -#[cfg(test)] -mod tests { - use super::*; - - // Generated with `openssl genpkey -algorithm ed25519 | openssl pkey -pubout`. - const PUBLIC_PEM: &str = - "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n-----END PUBLIC KEY-----\n"; - - /// The defaults clap would produce with no flags and no env at all. Built via - /// `try_parse_from` rather than by hand so the test exercises the REAL defaults - /// (`default_value` strings) instead of a second copy of them that could drift. - fn bare() -> Args { - Args::try_parse_from(["sandd-controller"]).unwrap() - } - - /// Defaults plus everything --enable-auth requires. - fn with_auth() -> Args { - Args { - enable_auth: true, - controller_id: Some("sandd-abc-uid".to_string()), - signing_public_key: Some(PUBLIC_PEM.to_string()), - signing_kid: Some("kid-1".to_string()), - ..bare() - } - } - - // The zero-config case: a bare `sandd-controller` must come up listening where its - // clients expect it. 0.0.0.0 specifically — daemons dial in from other machines, so - // a loopback default would start cleanly and serve nobody. - #[test] - fn defaults_to_the_port_daemons_dial() { - let config = resolve(bare()).unwrap(); - - assert_eq!(config.bind_addr, "0.0.0.0:8765"); - assert_eq!(config.auth, None); - } - - // The flag surface itself: --host/--port/--enable-auth must parse from argv, since - // that is the whole point of having flags rather than env vars alone. - #[test] - fn flags_are_parsed_from_argv() { - let args = Args::try_parse_from([ - "sandd-controller", - "--host", - "127.0.0.1", - "--port", - "9000", - "--enable-auth", - "--controller-id", - "sandd-abc-uid", - "--signing-public-key", - PUBLIC_PEM, - "--signing-kid", - "kid-1", - ]) - .unwrap(); - - let config = resolve(args).unwrap(); - - assert_eq!(config.bind_addr, "127.0.0.1:9000"); - let auth = config.auth.unwrap(); - assert_eq!(auth.controller_id, "sandd-abc-uid"); - assert_eq!(auth.kid, "kid-1"); - } - - // A PEM begins with `-----BEGIN PUBLIC KEY-----`, which clap reads as a flag unless - // the arg allows hyphen values. Without that the --signing-public-key FLAG is - // unusable (it fails with "unexpected argument '-----BEGIN...'"), while the env - // path works fine — so this breaks only for whoever runs the binary by hand, which - // is why it needs a test rather than a code reading. - #[test] - fn a_pem_is_accepted_as_a_flag_value_despite_its_leading_dashes() { - let args = Args::try_parse_from(["sandd-controller", "--signing-public-key", PUBLIC_PEM]) - .expect("a PEM must be accepted as a flag value"); - - assert_eq!(args.signing_public_key.as_deref(), Some(PUBLIC_PEM)); - } - - // A BARE `--enable-auth` must turn auth on. This is the ergonomic case a human - // types, and it only works because of `default_missing_value`. - #[test] - fn a_bare_enable_auth_flag_turns_auth_on() { - let args = Args::try_parse_from(["sandd-controller", "--enable-auth"]).unwrap(); - - assert!(args.enable_auth); - } - - // ...and `--enable-auth=false` must turn it OFF. With a plain SetTrue flag the - // value would be rejected outright, and — the reason this matters — merely SETTING - // SANDD_ENABLE_AUTH would enable auth, so `SANDD_ENABLE_AUTH=false` would turn it - // ON. That is the one direction this must not be wrong in. - #[test] - fn enable_auth_accepts_an_explicit_false() { - for (value, expected) in [ - ("true", true), - ("false", false), - ("1", true), - ("0", false), - ("yes", true), - ("no", false), - ("on", true), - ("off", false), - ("TRUE", true), - ("False", false), - ] { - let args = - Args::try_parse_from(["sandd-controller", &format!("--enable-auth={}", value)]) - .unwrap(); - - assert_eq!(args.enable_auth, expected, "--enable-auth={}", value); - } - } - - // An unrecognized value must be REFUSED, not silently falsy. `--enable-auth=enabled` - // meaning "disabled" is exactly the belief-vs-reality gap that ships an - // unauthenticated controller. - #[test] - fn an_unrecognized_enable_auth_value_is_an_error() { - for bogus in ["enabled", "y", "2", "maybe", "tru"] { - assert!( - Args::try_parse_from(["sandd-controller", &format!("--enable-auth={}", bogus)]) - .is_err(), - "--enable-auth={} must be refused rather than treated as off", - bogus - ); - } - // The parser is shared with the env path, so it is pinned directly too. - assert!(parse_bool("enabled").is_err()); - } - - // A typo'd host must be named at STARTUP. Left to `bind`, the error is "invalid - // socket address" with nothing pointing at what produced it. - #[test] - fn rejects_a_host_that_is_not_an_ip() { - let args = Args { - host: "not-an-ip".to_string(), - ..bare() - }; - - let err = resolve(args).unwrap_err(); - - assert!(err.contains("--host"), "error must name the flag: {}", err); - } - - // Port 0 binds SUCCESSFULLY to an OS-chosen port, so this cannot be left to bind: - // the daemons' URLs name a fixed port and every one of them would fail to connect. - #[test] - fn rejects_port_zero() { - let args = Args { port: 0, ..bare() }; - - let err = resolve(args).unwrap_err(); - - assert!(err.contains("--port"), "error must name the flag: {}", err); - } - - // Out-of-range and non-numeric ports are clap's job; pinned so a later switch to a - // hand-rolled parser cannot lose it. - #[test] - fn rejects_a_port_that_is_not_a_u16() { - for port in ["70000", "-1", "not-a-number"] { - assert!( - Args::try_parse_from(["sandd-controller", "--port", port]).is_err(), - "--port {} must be refused", - port - ); - } - } - - // Blank is treated as ABSENT: Kubernetes readily produces `NAME=""` (an env var - // with no value, a configMapKeyRef to an empty key), and "" is not a host. - #[test] - fn blank_values_fall_back_to_defaults() { - let args = Args { - host: " ".to_string(), - ..bare() - }; - - assert_eq!(resolve(args).unwrap().bind_addr, "0.0.0.0:8765"); - } - - #[test] - fn enable_auth_collects_the_verification_material() { - let args = Args { - token_issuer: Some("nebula-prod".to_string()), - ..with_auth() - }; - - let auth = resolve(args).unwrap().auth.unwrap(); - - assert_eq!(auth.controller_id, "sandd-abc-uid"); - assert_eq!(auth.public_key_pem, PUBLIC_PEM); - assert_eq!(auth.kid, "kid-1"); - assert_eq!(auth.issuer, "nebula-prod"); - } - - // Both sides default the issuer to the same literal, so the common deployment - // configures neither. If this drifts from the manager's default, every token is - // rejected for a wrong `iss` — with a signature that verified perfectly. - #[test] - fn issuer_defaults_to_the_managers_default() { - let auth = resolve(with_auth()).unwrap().auth.unwrap(); - - assert_eq!(auth.issuer, "nebula"); - } - - // THE central failure mode. With auth requested but material missing, the only - // acceptable outcome is an error: falling back to no-auth yields a controller that - // admits every caller while looking perfectly healthy. - #[test] - fn auth_on_with_missing_material_is_an_error_not_a_silent_downgrade() { - let cases: [(&str, fn(&mut Args)); 3] = [ - ("controller-id", |a| a.controller_id = None), - ("signing-public-key", |a| a.signing_public_key = None), - ("signing-kid", |a| a.signing_kid = None), - ]; - - for (flag, omit) in cases { - let mut args = with_auth(); - omit(&mut args); - - let err = resolve(args).unwrap_err(); - - assert!( - err.contains(flag), - "omitting --{} must fail with a message naming it, got: {}", - flag, - err - ); - } - } - - // Present-but-blank is what a missing Secret key actually produces in a Pod's - // environment, so it must fail the same way an absent value does. - #[test] - fn auth_on_with_blank_material_is_an_error() { - let cases: [(&str, fn(&mut Args)); 3] = [ - ("controller-id", |a| a.controller_id = Some(" ".into())), - ("signing-public-key", |a| { - a.signing_public_key = Some("".into()) - }), - ("signing-kid", |a| a.signing_kid = Some("\n".into())), - ]; - - for (flag, blank) in cases { - let mut args = with_auth(); - blank(&mut args); - - assert!(resolve(args).is_err(), "a blank --{} must be refused", flag); - } - } - - // The material is only required WITH auth. Omitting all of it while auth is off is - // the standalone/e2e shape and must start cleanly. - #[test] - fn material_is_not_required_when_auth_is_off() { - assert!(resolve(bare()).unwrap().auth.is_none()); - } - - // Auth is enabled by CONSTRUCTION: a config with auth yields a server holding a - // verifier, one without yields the unauthenticated shape. This is the seam where a - // wiring mistake would leave a controller listening with auth silently off. - #[test] - fn build_server_enables_auth_when_configured() { - assert!(build_server(resolve(with_auth()).unwrap()).is_ok()); - assert!(build_server(resolve(bare()).unwrap()).is_ok()); - } - - // A malformed key must kill the process BEFORE anything listens. Reaching the - // listener with an unusable key would mean either a panic mid-handshake or, far - // worse, a server that started with verification quietly inert. - #[test] - fn build_server_fails_on_an_unusable_key() { - let args = Args { - signing_public_key: Some( - "-----BEGIN PUBLIC KEY-----\nnope\n-----END PUBLIC KEY-----\n".to_string(), - ), - ..with_auth() - }; - let config = resolve(args).unwrap(); - - assert!(build_server(config).is_err()); - } - - // The env var names are a CONTRACT with Nebula's sanddControllerEnv. A rename on - // either side presents as every daemon failing to authenticate, with nothing in the - // logs pointing at the cause. Asserted through clap's own metadata, so this pins - // what the binary ACTUALLY reads rather than a duplicate list of literals. - #[test] - fn env_var_names_match_the_nebula_contract() { - use clap::CommandFactory; - - let cmd = Args::command(); - let env_of = |id: &str| -> String { - cmd.get_arguments() - .find(|a| a.get_id() == id) - .unwrap_or_else(|| panic!("no such arg: {}", id)) - .get_env() - .unwrap_or_else(|| panic!("{} must be readable from an env var", id)) - .to_string_lossy() - .to_string() - }; - - assert_eq!(env_of("controller_id"), "SANDD_CONTROLLER_ID"); - assert_eq!(env_of("signing_public_key"), "SANDD_SIGNING_PUBLIC_KEY"); - assert_eq!(env_of("signing_kid"), "SANDD_SIGNING_KID"); - assert_eq!(env_of("token_issuer"), "SANDD_TOKEN_ISSUER"); - assert_eq!(env_of("enable_auth"), "SANDD_ENABLE_AUTH"); - } - - // The port must stay equal to nebulav1alpha1.SanddControllerPort, which is also - // baked into the daemon's dial-out URL. Changing one side alone means daemons - // connect to a closed port. - #[test] - fn default_port_matches_the_daemon_dial_url() { - assert_eq!(DEFAULT_PORT, 8765); - } - - // clap panics at RUNTIME on a malformed command definition (conflicting ids, a bad - // default_value for the value_parser), which would otherwise only surface when the - // container starts. This asserts the definition is well-formed at test time. - #[test] - fn the_command_definition_is_valid() { - use clap::CommandFactory; - - Args::command().debug_assert(); - } -} diff --git a/server/src/python.rs b/server/src/python.rs index 6067aa9..0e5d121 100644 --- a/server/src/python.rs +++ b/server/src/python.rs @@ -1,15 +1,10 @@ //! The Python bindings — `sandd.Server` and friends, built by maturin into the //! `sandd._core` extension module. //! -//! Behind the `python` feature (off by default) so the CONTROLLER BINARY does not -//! link CPython: `src/main.rs` needs only registry + server + auth, and a `cargo -//! build` that pulls in pyo3/extension-module cannot link at all on macOS (the -//! extension is meant to be dlopened by an interpreter that provides the symbols). -//! Gating the layer is what lets one crate produce both artifacts. -//! -//! Everything below is unchanged from when it lived in lib.rs; only the tunnel -//! helpers moved with it, because tunnel mode is a Python-side (`connect="tunnel"`) -//! option and the binary is direct-dial only. +//! Behind the `python` feature (off by default) so a plain `cargo build`/`cargo test` +//! does not link CPython: pyo3/extension-module cannot link at all on macOS outside +//! an interpreter (the extension is meant to be dlopened by one that provides the +//! symbols). use anyhow::Context; use pyo3::exceptions::{PyRuntimeError, PyTimeoutError, PyValueError};