Personal Monorepo ❄️
README.md

tangled-spindle-nix#

A Rust reimplementation of the Tangled Spindle CI runner that replaces Docker-based isolation with native Nix for dependency management and systemd service-level sandboxing for isolation.

Includes a NixOS module (services.tangled-spindles) for declarative multi-runner deployment, modeled after services.github-runners.

Why?#

The upstream spindle is written in Go and requires Docker (or Podman) to isolate pipeline steps. Every step runs in a fresh container whose image is built on-the-fly via Nixery. This works well, but on a NixOS host it introduces unnecessary overhead:

  • Docker is heavy — a full container runtime, overlay filesystem driver, and root-equivalent daemon
  • Nixery round-trips are redundant — the Nix store already has (or can build) every package locally
  • systemd already provides isolation — a hardened systemd service with DynamicUser=, PrivateTmp=, ProtectSystem=strict, seccomp, and cgroups gives container-grade sandboxing without a container runtime. All child processes (workflow steps) inherit this automatically.
  • NixOS modules are the idiomatic deployment method — declaratively spin up N runners with per-runner configuration, secrets, and hardening

Architecture#

                    ┌──────────────────────────────────┐
                    │       tangled-spindle-nix         │
                    │                                   │
 Jetstream ───────► │  Ingester (member/repo/collab)    │
 (AT Protocol)      │           │                       │
                    │           ▼                        │
 Knot events ─────► │  Event Consumer (sh.tangled.pipe) │
                    │           │                        │
                    │           ▼                        │
                    │       Job Queue                    │
                    │           │                        │
                    │           ▼                        │
                    │   ┌───────────────────┐            │
                    │   │  Engine: nix      │            │
                    │   │                   │            │
                    │   │ 1. nix build      │            │
                    │   │ 2. fork/exec step │            │
                    │   │ 3. stream logs    │            │
                    │   └───────────────────┘            │
                    │                                    │
                    │   HTTP: /events /logs /xrpc/*      │
                    └──────────────────────────────────┘

Instead of Docker containers, each workflow step is executed as a child process of the runner daemon. The runner's systemd service provides all sandboxing — child processes inherit it automatically, just like github-runners:

Concern Docker (upstream) nix engine (this project)
Process isolation Container namespaces systemd service-level sandboxing (inherited by children)
Dependencies Nixery image pull over HTTPS nix build from local store
Filesystem Overlay FS Service-level ProtectSystem=strict, PrivateTmp=, ReadWritePaths=
User isolation Container root mapped to host uid DynamicUser=true on the runner service
Resource limits Docker cgroup limits Service-level CPUQuota=, MemoryMax=, TasksMax=
Network isolation Docker bridge Service-level PrivateNetwork= (configurable)
Log streaming Docker attach stdout/stderr Piped stdout/stderr from child process
Cleanup Container removal Workspace directory cleanup

NixOS Module#

{
  services.tangled-spindles = {
    runner1 = {
      enable = true;
      hostname = "spindle1.example.com";
      owner = "did:plc:abc123";
      tokenFile = "/run/secrets/spindle1-token";
    };

    runner2 = {
      enable = true;
      hostname = "spindle2.example.com";
      owner = "did:plc:def456";
      tokenFile = "/run/secrets/spindle2-token";
      engine.maxJobs = 4;
      engine.workflowTimeout = "30m";
      secrets.provider = "openbao";
    };
  };
}

Each entry produces an independent tangled-spindle-{name}.service systemd unit with its own state directory, log directory, database, and RBAC configuration. Sandboxing is applied at the service level and inherited by all workflow step child processes — no systemd-run, polkit rules, or elevated capabilities needed.

Compatibility#

This project is wire-compatible with the upstream Go spindle:

  • Same WebSocket event format on /events and /logs/{knot}/{rkey}/{name}
  • Same XRPC endpoints and service auth
  • Same JSON log line schema
  • Same SPINDLE_SERVER_* environment variable configuration
  • Runs the same .tangled/workflows/*.yml pipeline manifests

An appview or tangled.org frontend that works with the Go spindle works identically with this one.

Development#

# Enter the dev shell
cd tangled-spindle-nix
just build   # cargo build
just test    # cargo test
just check   # cargo clippy + cargo fmt --check
just run     # cargo run (requires SPINDLE_SERVER_* env vars)

Status#

🚧 Early development — see PLAN.md for the full implementation plan and phase breakdown.

License#

MIT