You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The objective of this initiative is to extend sparkstack from a single-node deployment system to a fully distributed orchestration platform. This will allow the core orchestrator, gateway (OpenClaw), and monitoring infrastructure to reside on a lightweight head node (e.g., pike), while heavy LLM inference backends (e.g., vLLM) are delegated to specialized, GPU-heavy remote worker machines (e.g., spark, oldbook).
1.1 Delegation Model: sparkrun-native
Architectural decision:sparkstack continues to delegate backend container lifecycle to sparkrun via sparkrun run --hosts. It does NOT directly manage remote Docker daemons for backends. sparkstack's new responsibilities are limited to:
Infrastructure pre-deployment — deploying Tailscale sidecars on remote workers (and the head node) before sparkrun launches backends.
Network flag orchestration — passing the correct --network and environment flags to sparkrun so backends join the Tailscale sidecar's network namespace.
Config generation — generating LiteLLM configs with Tailnet IPs for remote backend routing.
This avoids reimplementing sparkrun's model distribution, health checking, container lifecycle, and SSH execution.
1.2 Integration Approach: Shell-Out to sparkrun CLI
Architectural decision:sparkstack interacts with sparkrun exclusively via CLI subprocesses (e.g., uv run sparkrun cluster status --json). It does NOT import sparkrun's internal Python APIs directly.
Rationale:
sparkstack already follows this pattern in launch.py (shelling out to sparkrun run).
Loose coupling keeps sparkrun's internal API surface free to evolve without breaking sparkstack.
CLI commands like cluster status --json and stop are the public, stable interface.
Subprocess overhead is negligible for infrequent orchestration calls (deploy, teardown, health-check).
Available sparkrun CLI commands for orchestration:
sparkstack needs to...
sparkrun CLI command
JSON output?
Check backends running on hosts
sparkrun cluster status --cluster X --json
✅
Check a specific job's liveness
sparkrun cluster check-job {target} --cluster X --json
✅
Stop backends on specific hosts
sparkrun stop {recipe} --cluster X
✅ (exit code)
Stop all sparkrun containers
sparkrun stop --all --cluster X
✅ (exit code)
Get backend logs from remote hosts
sparkrun logs {cluster_id}
N/A (streaming)
State management split: Backend state (hosts, ports, container names, health) is queried from sparkrun via these CLI commands at the point of need. Sparkstack maintains its own .state.json only for Tailscale infrastructure that sparkrun has no concept of (see Step 6).
Future consideration: Tightening the dependency via direct Python imports (e.g., from sparkrun.orchestration.job_metadata import load_job_metadata) is identified as a high-value future refactor. sparkrun is already an editable path dependency, making this a zero-friction change when the API surface stabilizes.
1.3 Networking & Security: Headscale + Tailscale
To ensure secure, seamless communication across the cluster, we implement an Encrypted Network Overlay using Headscale (Control Plane) and Tailscale (Data Plane).
Control Plane (Headscale): A central Headscale server (pinned to v0.28.x, the current stable release) deployed on the head node. Manages node identities, IP allocation, and access controls without external SaaS. MagicDNS is disabled — all routing uses raw Tailnet IPs for simplicity and resilience in automated orchestration.
Head Node (Tailscale Sidecar): The head node runs a Tailscale sidecar container (sparkstack-head-sidecar) attached to the local Docker bridge networks. Core routing services (LiteLLM) and monitoring services (Alloy/Prometheus) share this sidecar's network namespace. This ensures zero interference with any pre-existing Tailscale installation on the host OS.
Worker Nodes (Tailscale Sidecar Containers): Worker machines remain fully agentless. The official tailscale/tailscale Docker image is deployed as a standalone sidecar container on each worker via SSH. Backend containers (vLLM) join the sidecar's network namespace using network_mode: container:{sidecar_name}, binding to 0.0.0.0:{port} inside the sidecar's network namespace. This makes backends reachable at the sidecar's Tailnet IP without exposing any ports on the worker's physical network.
Security: All cross-node traffic (inference requests, OTLP traces, Prometheus scrapes) is encrypted peer-to-peer via WireGuard. No application-layer TLS or API keys are needed for internal communications.
Monitoring a distributed setup requires a robust, location-agnostic telemetry pipeline.
Lightweight Daemons: Remote worker hosts will run minimal, high-performance C daemons (such as nv-monitor) to expose native, Prometheus-compatible system and GPU endpoints.
Encrypted Exporters: Exporters running on remote nodes are accessed via their Tailscale sidecar's Tailnet IP, ensuring metrics are never exposed to the public internet.
Centralized Orchestration Events: Because the sparkstack orchestrator runs entirely on the head node (managing remote nodes via SSH), deployment lifecycle events are generated locally. They are broadcast directly to the local Unix Domain Socket (UDS) (/tmp/sparkstack.sock) without needing to cross the Tailscale network.
Seamless TUI Integration: The sparkstack status CLI reads exclusively from the local UDS. This keeps the user interface entirely agnostic of whether the deployment progress is happening locally or remotely.
2.1 Observability Event Flow
sequenceDiagram
box LightYellow Head Node
participant Scraper as Alloy / Prometheus
participant TS_Head as Head Tailscale Sidecar
participant Orchestrator as sparkstack (Local)
participant UDS as Unix Domain Socket<br/>(/tmp/sparkstack.sock)
participant TUI as sparkstack status<br/>(TUI)
end
box LightGreen Tailscale Overlay
participant Mesh as WireGuard Network
end
box LightBlue Remote Worker Node
participant TS_W as Tailscale Sidecar
participant Daemon as nv-monitor (Daemon)
end
Note over Scraper,Daemon: Metrics Scraping (Pull-based)
Scraper->>TS_Head: Scrape http://100.x.y.2:9400/metrics
TS_Head->>Mesh: Encrypts & Routes
Mesh->>TS_W: Delivers Request
TS_W->>Daemon: Forward to localhost:9400
Daemon-->>TS_W: Metrics Response
TS_W-->>Mesh: Return via Tailnet
Mesh-->>TS_Head: Deliver Response
TS_Head-->>Scraper: Metrics Data
Note over Orchestrator,TUI: Deployment Events (Local only)
Orchestrator->>UDS: Emits Deployment Lifecycle Events
UDS->>TUI: Live Dashboard UI Updates
Loading
3. User Experience & Cluster Configuration
To avoid maintaining parallel cluster definitions, sparkstack will reuse sparkrun's existing ClusterManager infrastructure for host resolution and SSH configuration.
3.1 sparkrun Cluster Reuse
sparkrun already stores cluster definitions at ~/.config/sparkrun/clusters/<name>.yaml via its ClusterManager. These include hosts, SSH users, cache directories, and transfer mode.
sparkstack extends this by maintaining a thin supplementary config at ~/.config/sparkstack/clusters/<name>.yaml that stores only sparkstack-specific metadata not present in sparkrun's schema:
# ~/.config/sparkstack/clusters/mylab.yaml# References the sparkrun cluster of the same name for host/SSH details.sparkrun_cluster: mylabheadscale_server: 192.168.1.100:8080head_tailnet_ip: 100.64.0.1 # Persisted after initial setup
Host lists, SSH users, and cache directories come from sparkrun's cluster definition. This ensures a single source of truth for host inventory.
3.2 Target Resolution & CLI
Users can choose to target specific hosts manually using inline overrides, or deploy across an entire saved cluster:
When --cluster is used, sparkstack reads the sparkrun cluster configuration for host inventory and SSH settings, and its own supplementary config for Tailnet metadata. Host resolution, validation, and SSH connection logic are delegated to sparkrun's existing ClusterManager.
vllm-network (Local Docker Bridge): Shared network for local inference traffic.
Tailscale Overlay (100.x.y.z): Remote worker nodes communicate exclusively over the encrypted Tailnet. The head node reaches these IPs via its sparkstack-head-sidecar container.
Key rule: Remote backends do NOT join sparkstack-net or vllm-network. They are only reachable via their Tailnet IPs.
4.2 Network Planes and Routing Diagram
The following diagram illustrates how the Control Plane (Headscale), Data Plane (Tailscale Overlay), and Application traffic interact across the physical network and Docker bridges:
graph TD
classDef lan fill:#e1f5fe,stroke:#01579b,stroke-width:2px,stroke-dasharray: 4 4;
classDef tailnet fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
classDef node fill:#fff,stroke:#333,stroke-width:1px;
subgraph LAN ["Physical LAN (e.g., 192.168.1.x)"]
direction LR
subgraph Head_Node ["Head Node (192.168.1.100)"]
Headscale["fa:fa-server Headscale<br/>(Control Plane)"]
OpenClaw["fa:fa-brain OpenClaw<br/>(sparkstack-net)"]
subgraph Head_Sidecar ["sparkstack-head-sidecar (100.x.y.1)"]
LiteLLM["LiteLLM Router"]
Alloy["Alloy Scraper"]
end
end
subgraph Worker_Node ["Worker Node (192.168.1.101)"]
subgraph Worker_Sidecar ["sparkstack-sidecar-worker (100.x.y.2)"]
vLLM["vLLM Backend"]
end
end
end
%% Control Plane Flow
Worker_Sidecar -.->|"1. Control Plane (HTTP:8080 over LAN)<br>Auth & Key Exchange"| Headscale
Head_Sidecar -.->|"1. Control Plane"| Headscale
%% Data Plane Flow
Head_Sidecar ===>|"2. Data Plane (WireGuard UDP)<br>Encrypted Overlay (100.x.y.z)"| Worker_Sidecar
%% Application Flow
OpenClaw -->|"3a. App Request (Docker Bridge)"| LiteLLM
LiteLLM -->|"3b. App Request (over Tailnet)"| vLLM
Alloy -->|"3c. Metrics Scrape (over Tailnet)"| vLLM
class LAN lan;
class Head_Node,Worker_Node node;
class Head_Sidecar,Worker_Sidecar tailnet;
Loading
4.3 Head Node Configuration (Headscale)
Deployment & Exposure: Headscale runs on the head node as a Docker container on sparkstack-net. It must expose a port to the host (e.g., ports: ["8080:8080"]).
Routable Control Plane: Remote Tailscale sidecars require continuous access to Headscale for node map updates and key rotation. SPARKSTACK_HEADSCALE_SERVER must be set to the head node's routable LAN IP or DNS name. The sparkstack CLI will auto-detect the host's primary LAN IP, but users can override this.
Configuration: A base config.yaml is stored in services/headscale/config/, configured to reject open registrations. MagicDNS is disabled (dns.magic_dns: false) — all service routing uses raw Tailnet IPs, avoiding DNS resolution dependencies in the automation path. Hostnames are still set via TS_HOSTNAME for human-readable tailscale status output, but they are not used for routing.
Pre-Auth Keys: During sparkstack setup, the CLI generates a persistent Headscale pre-auth key and saves it to .env as SPARKSTACK_HEADSCALE_AUTH_KEY.
4.4 Head Node Configuration (Tailscale Sidecar)
To achieve 100% isolation from any existing Tailscale network on the head node, we deploy a Head Node Sidecar instead of installing Tailscale on the host.
Deployment: A tailscale/tailscale container named sparkstack-head-sidecar is deployed on the head node.
Network Attachments: It is explicitly attached to sparkstack-net and vllm-network.
Port Exposure: Since other containers (LiteLLM, Alloy) will share its network namespace, the sparkstack-head-sidecar MUST expose their required ports to the host and bridge networks (e.g., -p 4000:4000 for LiteLLM, -p 4318:4318 for OTLP, -p 9090:9090 for Prometheus). This host-port exposure is intentional — it enables local CLI tools (smoke tests, curl debugging, sparkstack wait) to reach LiteLLM at localhost:4000 without routing through Docker DNS.
Zero-Touch: It authenticates to the local Headscale server using the same SPARKSTACK_HEADSCALE_AUTH_KEY as remote workers.
Standalone Sidecar: Before any backend is deployed, sparkstack deploys a tailscale/tailscale container on the remote host via SSH + docker run. This sidecar runs independently — it is NOT part of the backend's compose file.
Network Namespace Sharing: sparkrun launches the backend container with network_mode: container:{sidecar_name}, binding to 0.0.0.0:{port} inside the sidecar's namespace.
Zero-Touch Provisioning: The remote host requires no manual configuration. The sidecar authenticates using SPARKSTACK_HEADSCALE_AUTH_KEY injected at deploy time.
Sidecar Configuration:
TS_STATE_DIR=/var/lib/tailscale (explicit, matching the volume mount)
TS_ACCEPT_DNS=false (prevents sidecar from overriding container DNS)
TS_HOSTNAME=sparkstack-{role}-{host} (human-readable identifier in tailscale status, not used for routing)
Healthcheck: tailscale status --json to verify mesh connection before backend launch
4.6 Gateway Routing (Head Node → Remote Backends)
LiteLLM and Monitoring share the Head Node Sidecar.
Because we avoid installing Tailscale on the host, head node services that need to route to the Tailnet must do so via the sparkstack-head-sidecar:
LiteLLM, Alloy, and Prometheus are launched with network_mode: container:sparkstack-head-sidecar.
The sparkstack-head-sidecar container is attached to sparkstack-net and vllm-network.
Other services on sparkstack-net (like OpenClaw) can reach LiteLLM by calling http://sparkstack-head-sidecar:4000 (since LiteLLM shares the sidecar's network namespace).
For remote backends, LiteLLM routes via the Tailnet naturally because it shares the sidecar's networking stack: backend_url=http://{worker_tailnet_ip}:{port}/v1.
For local backends, LiteLLM continues using Docker DNS via the shared bridge attachment: http://main_solo:8000/v1.
This achieves total network isolation without requiring any host-level software dependencies on the head node.
Schema Update: Update the Pydantic schema for model requests to accept an optional target field (hostname from sparkrun's cluster definition).
Extraction: Modify _process_model_request() to parse the target parameter from the model override dictionary.
Context Injection: Inject the parsed target_host into the context dictionary passed to all service handlers. This ensures all downstream builders (Docker, LiteLLM, monitoring) are aware of the deployment destination.
Locality Flag: Add a is_remote boolean derived from target_host != "localhost". This flag drives all conditional logic below.
Target File:sparkstack/manager/launch.py (new helper: sparkstack/manager/remote.py)
This step deploys Tailscale sidecars on remote workers before sparkrun launches any backends. This resolves the IP resolution chicken-and-egg: sidecar Tailnet IPs are known before LiteLLM config generation.
ssh user@{host} docker exec sparkstack-sidecar-{role} tailscale ip -4
Store the mapping {role} → {tailnet_ip} in the build context for use by LiteLLM config generation (Step 5).
Persist the mapping to the local state file (see Step 7).
Phase 3: Launch backends via sparkrun
For each backend, launch_stack() calls sparkrun run with modified flags based on locality:
cmd= [
"uv", "run", "sparkrun", "run", str(recipe_path),
"--hosts", backend["target"],
"--port", str(backend["port"]),
...
"--solo", "--no-follow",
]
ifbackend["is_remote"]:
# Remote: join the pre-deployed sidecar's network namespacecmd.extend(["-o", f"network=container:sparkstack-sidecar-{backend['role']}"])
# Inject OTEL endpoint pointing to head node's Tailnet IPcmd.extend(["-o", f"env.OTEL_EXPORTER_OTLP_ENDPOINT=http://{head_tailnet_ip}:4318"])
else:
# Local: join the local Docker bridge (current behavior)cmd.extend(["-o", f"network={global_network}"])
Key change: Remote backends use network=container:sparkstack-sidecar-{role} instead of network=sparkstack-net. This makes the backend share the sidecar's network namespace, binding to 0.0.0.0:{port} inside it. The backend becomes reachable at the sidecar's Tailnet IP. The --solo flag remains compatible because sparkrun run --solo passes -o network=... directly to docker run --network=....
Validated override path (Phase 0 complete): The -o network=container:... override has been confirmed to work end-to-end through sparkrun's existing code:
_parse_options() parses network=container:sparkstack-sidecar-main into a dict (coerce_value correctly preserves the container: prefix as a raw string)
_apply_recipe_overrides() merges the override into the recipe config chain
ExecutorConfig.from_chain() resolves the network field
Deployment: Headscale will be deployed as a foundational core service on the head node, initialized alongside other orchestration services on sparkstack-net.
Version Pinning: Pin to headscale/headscale:0.28.0 (current stable as of 2026-05). The Headscale API surface changed significantly between 0.22→0.23 (gRPC replaced with REST). v0.28 uses the headscale CLI for pre-auth key generation:
State Persistence: Mount a persistent Docker volume (headscale-data:/var/lib/headscale) to ensure node identities, cryptographic keys, and overlay IP assignments survive container restarts.
User Management: Headscale v0.28 does not auto-create users. The setup flow must explicitly create and manage the sparkstack namespace user before generating pre-auth keys. User creation is idempotent — re-running on an existing user is a no-op error that the setup script handles gracefully.
Auth Flow & Provisioning:
During sparkstack setup, the CLI starts Headscale and waits for it to become healthy.
Store the key as SPARKSTACK_HEADSCALE_AUTH_KEY in .env.
The head node sidecar uses this to connect. Its Tailnet IP is retrieved and stored as SPARKSTACK_HEAD_TAILNET_IP in .env.
SPARKSTACK_HEADSCALE_SERVER is auto-detected from the host's primary LAN IP (user-overridable in .env).
Key lifecycle: The pre-auth key has a 10-year expiration (87600h). If the key is rotated or expires, re-run sparkstack setup to regenerate. All existing sidecars remain authenticated — only new sidecar enrollments require a valid key.
Step 4: Service Handler & Gateway Routing Configuration
Backend Target Definition: Update the backend dictionary returned by apply_to_builders() to include "target": self.context["target_host"] and "is_remote": self.context["is_remote"].
LiteLLM Backend URL Generation:
If is_remote: backend_url = f"http://{tailnet_ip_map[role]}:{port}/v1" — uses the Tailnet IP resolved in Step 2, Phase 2.
If local: backend_url = f"http://{container_hostname}:{port}/v1" — unchanged from today, uses Docker DNS on sparkstack-net.
LiteLLM and Monitoring Namespace Sharing:
Update litellm and monitoring Compose builders to output network_mode: container:sparkstack-head-sidecar instead of traditional networks blocks.
No standalone gateway sidecar: LiteLLM is effectively inside the head node's sidecar, so it reaches remote Tailnet IPs directly and accesses sparkstack-net hosts transparently.
OTEL Endpoint Injection: For remote sparkrun invocations, the OTEL_EXPORTER_OTLP_ENDPOINT environment variable is overridden to http://{SPARKSTACK_HEAD_TAILNET_IP}:4318 (read from .env, persisted at setup time). Local backends continue using http://alloy:4318 via Docker DNS.
The current _check_constraints() sums VRAM and RAM across all backends and checks against a single host's ceiling. With multi-node, resource budgets must be per-host.
Host Resource Registry: Extend the cluster supplementary config (Section 3.1) to support per-host resource overrides:
Constraint Check Refactor:_check_constraints() groups backends by target_host and validates each group against that host's resource limits. If no per-host override exists, fall back to the current global defaults from env.py.
Step 6: State Tracking & Two-Tier Orphan Teardown
Target File:sparkstack/manager/remote.py (new)
6.1 State Management Principle
sparkrun is the authority for backend state; sparkstack tracks only the infrastructure sparkrun doesn't know about (Tailscale sidecars).
This avoids state duplication. Backend metadata (hosts, ports, container names, health) lives in sparkrun's existing state system (~/.cache/sparkrun/jobs/, sparkrun cluster status). Sparkstack maintains a lightweight file tracking only Tailscale-sidecar-specific metadata.
6.2 Sidecar State File
State File:sparkstack-registry/stacks/{STACK_NAME}/.state.json
The cluster_name field is required for teardown resolution — set_current.py receives a stack_name and stack_path but has no way to derive the sparkrun cluster name without this mapping.
This file contains only data that sparkrun has no concept of:
Sidecar container names (Tailscale infrastructure managed by sparkstack)
Tailnet IPs assigned by Headscale
Role-to-host mapping for sidecar association
It does NOT contain backend container names, recipe info, ports, or any data already tracked by sparkrun.
Write on deploy: After Phase 2 (IP resolution) succeeds, write/update the sidecar state file. This happens before sparkrun launches backends.
6.3 Two-Tier Orphan Teardown
Teardown is split into two tiers matching the authority model:
Tier 1: Backend teardown (delegated to sparkrun)
Before deploying a new stack version, sparkstack delegates backend cleanup to sparkrun via CLI:
# Option A: Stop a specific job by recipe name
uv run sparkrun stop {recipe_name} --cluster {cluster_name}
# Option B: Stop all sparkrun containers across the cluster
uv run sparkrun stop --all --cluster {cluster_name}
# Option C: Query what's running first, then stop selectively
uv run sparkrun cluster status --cluster {cluster_name} --json
# Parse output, then stop specific cluster IDs
uv run sparkrun stop sparkrun_{cluster_id} --cluster {cluster_name}
This ensures sparkrun's internal metadata (job files, pending ops) stays synchronized. Never directly docker rm -f sparkrun-managed containers — that would leave orphaned metadata in ~/.cache/sparkrun/jobs/.
Tier 2: Sidecar teardown (managed by sparkstack)
After backends are stopped, sparkstack handles sidecar cleanup using .state.json:
Compare the new target host set against the persisted sidecar state.
For any host present in state but absent in the new deployment:
Partial failure handling: If sidecar deployment succeeds but backend launch fails, the sidecar is left running (idempotent; next deploy reuses it). Sidecar state is written immediately after sidecar health confirmation, independent of backend launch success.
Step 7: Error Handling & Resilience
SSH Failures: If SSH connection to a remote host fails during any phase, the orchestrator must fail fast and surface a clear network error in the UDS event stream (/tmp/sparkstack.sock), rather than hanging indefinitely.
Sidecar Health Timeout: Sidecar health polling (Phase 1) must have a configurable timeout (default: 60s). If the sidecar fails to connect to the Tailnet within the timeout, abort deployment for that target with a descriptive error.
Sidecar Docker Healthcheck: The sidecar container includes a Docker healthcheck for use with depends_on in future compose-based approaches:
Idempotent Sidecar Deployment: Before creating a sidecar, check if one already exists for that role on the target host. If it exists and is healthy, skip creation. If it exists but is unhealthy, remove and recreate.
Orphaned Containers: See Step 6 for the state-tracked orphan teardown mechanism.
Step 8: Monitoring, Health Probes & Discovery Updates
The following subsystems hardcode localhost or assume local Docker access for health checks, service discovery, and teardown. They require conditional logic based on backend locality.
Current assumption:add_target() registers scrape targets using local Docker DNS names (e.g., main_solo:8001). Prometheus on the head node can only resolve these for local containers.
Required change:
For remote backends, add_target() must register the Tailnet IP instead of the Docker hostname: {tailnet_ip}:{port} rather than {container_hostname}:{port}.
This means SparkrunServiceHandler.apply_to_builders() must pass the resolved Tailnet IP into the monitoring builder when is_remote is true.
Local backends continue using Docker DNS names (no change).
Cross-ref: Step 4 (Service Handler & Gateway Routing) must be extended to cover this.
8.2 Wait-for-Backends Health Probe (sparkstack/manager/wait_for_backends.py)
Current assumptions:
Progress Manager probe (line 47):http://localhost:8126/status — assumes the sparkrun progress manager runs on the head node. This remains correct because sparkrun's progress manager aggregates status centrally, but must be validated for remote backends.
Crash log retrieval (line 74):docker logs --tail 20 {container} — runs against the local Docker daemon. For remote backends, the container does not exist locally.
Smoke tests (lines 245, 252):http://localhost:{litellm_port}/v1/... — smoke tests run through LiteLLM on the head node. This remains correct regardless of backend location (LiteLLM proxies to remote backends).
Required changes:
Crash log retrieval: For remote backends, use ssh user@{host} docker logs --tail 20 {container} instead of local docker logs. The backend's target_host must be available in the service discovery data (see 8.3).
Smoke tests: No change needed — they route through the local LiteLLM gateway, which handles remote routing transparently.
8.3 Service Discovery (sparkstack/core/discovery.py)
Current assumptions:
get_container_name_by_port() (line 21): Runs docker ps and docker exec against the local Docker daemon to discover which container owns a port. This is meaningless for remote backends whose containers exist on a different host.
get_active_services() (line 70): Parses litellm-config.yaml to discover backends by API base URL. The existing logic at line 114 already handles non-localhost hostnames by using the hostname as the container name — this partially works for Tailnet IPs, but the returned container field will be an IP address instead of a Docker container name, which may confuse downstream callers.
Required changes:
Semantic split for container field: The container field in service entries must remain a Docker container name (used for docker logs in wait_for_backends.py:74). A separatetailnet_ip field carries the Tailnet IP for remote routing. The target_host field carries the SSH-reachable hostname for remote operations. Never overload container with an IP — that breaks crash log retrieval.
get_active_services() should be extended to enrich service entries with three new fields:
target_host: SSH-reachable hostname (e.g., spark)
is_remote: boolean flag
tailnet_ip: Tailnet IP for routing (from .state.json)
container: Docker container name (from sparkrun cluster status --json)
For remote backend info (host, port, container name), query sparkrun via uv run sparkrun cluster status --cluster {name} --json. For Tailnet IP mapping, read .state.json.
This enables wait_for_backends.py to use ssh user@{target_host} docker logs --tail 20 {container} for remote crash log retrieval.
get_container_name_by_port() should short-circuit and return None for remote backends (the caller already handles this gracefully).
8.4 Services Manager Health Probes (sparkstack/manager/services.py)
Current assumptions:
LiteLLM health (line 297):HttpProbe("http://localhost:4000/health") — correct; LiteLLM always runs locally on the head node.
Progress manager health (line 332):HttpProbe("http://localhost:8126/status") — correct; the progress manager runs locally.
No changes needed. Both probed services are head-node-local.
Local container cleanup (lines 55-61):docker ps -a -q -f name=sparkrun|vllm|... followed by docker rm -f — only kills containers on the local Docker daemon. Remote backend containers are untouched.
Local network prune (line 64): Only affects local Docker networks.
Local LiteLLM removal (line 72): LiteLLM is locally managed (though now sharing the sidecar's namespace).
Required changes:
Before performing local cleanup, set_current.py must invoke the two-tier teardown from Step 6:
Tier 1 (backends): Shell out to sparkrun stop --all --cluster {name} to let sparkrun handle backend container teardown and its own metadata cleanup.
Tier 2 (sidecars): Read the outgoing stack's .state.json and SSH-remove sidecar containers from hosts no longer needed.
The local docker ps filter remains for local containers.
8.6 Monitor CLI (sparkstack/cli/_monitor.py)
Current assumptions:
Line 331: http://localhost:8126/status — progress manager polling.
Line 352: http://localhost:18789/healthz — another local health endpoint.
No changes needed. These are head-node-local services.
Current assumption (line 127):"target": "localhost" is hardcoded in the backend dict.
Required change: Already addressed in Step 1 + Step 4 — the target field will be set from self.context["target_host"] instead of hardcoded "localhost".
These use Docker DNS names that only resolve on the head node's sparkstack-net bridge.
Required change: Already addressed in Step 4 — for remote backends, these are overridden to http://{SPARKSTACK_HEAD_TAILNET_IP}:4318. The handler sets them at build time; launch.py reinforces them at launch time via -o env.OTEL_EXPORTER_OTLP_ENDPOINT=....
Current assumption:backend_url is constructed from container_hostname (Docker DNS), which only resolves locally.
Required change: Already addressed in Step 4 — for remote backends, backend_url uses the resolved Tailnet IP instead of the Docker hostname.
8.10 Global Resource Constants (sparkstack/core/env.py)
Current assumptions:
USABLE_SPARK_MEMORY_GB = 121.0 — hardcoded for a single DGX Spark.
MAX_DOCKER_MEMORY_GB — derived from the single-node pool.
MAX_VRAM_UTILIZATION = 0.95 — single GPU pool ceiling.
Required change: Already addressed in Step 5 — per-host resource overrides in the cluster supplementary config supersede these globals. The globals become defaults for hosts without explicit overrides.
Appendix A: Single-Node Assumptions Summary Table
File
Line(s)
Assumption
Plan Step
Status
core/schemas.py
14-19
ModelRequest has no target field
Step 1
🔲 New
core/env.py
44, 53, 56
Global MAX_DOCKER_MEMORY_GB, MAX_VRAM_UTILIZATION
Step 5
🔲 New
core/builders/stack.py
107-140
_check_constraints() sums globally
Step 5
🔲 New
core/builders/stack.py
193-270
_process_model_request() has no target parsing
Step 1
🔲 New
core/handlers/sparkrun.py
127
"target": "localhost" hardcoded
Step 1 + Step 4
🔲 New
core/handlers/sparkrun.py
161-167
OTEL endpoints use Docker DNS (alloy:4318)
Step 4
🔲 New
core/handlers/sparkrun.py
182-186
Monitoring targets use Docker DNS hostnames
Step 8.1
🔲 New
core/handlers/sparkrun.py
221
backend_url uses container_hostname (Docker DNS)
Step 4
🔲 New
core/builders/monitoring.py
22-26
add_target() uses Docker DNS names for scrape targets
Step 8.1
🔲 New
core/builders/litellm.py
28-92
backend_url param assumed to be Docker DNS
Step 4
🔲 New
core/discovery.py
16-67
get_container_name_by_port() uses local docker ps/exec
Step 8.3
🔲 New
core/discovery.py
70-127
get_active_services() no remote metadata; needs sparkrun CLI + .state.json
LITELLM_BASE_URL must change from http://litellm:4000 to http://sparkstack-head-sidecar:4000 (LiteLLM now shares sidecar namespace, not its own container name)
Step 4
🔲 New
Appendix B: Implementation Briefing for Agents
Read this section before writing any code. It captures decisions, constraints, and anti-patterns from the design phase that are not obvious from the plan text alone.
B.1 Repository Layout
~/p/sparkstack/ # This repo. All implementation happens here.
~/p/sparkrun/ # Sibling repo. Editable path dependency (see pyproject.toml).
# MUST be on `local-dev` branch before any modifications.
# NO sparkrun changes are needed for this feature.
~/p/openclaw/ # Sibling repo. Read-only source dependency on `local-dev` branch.
~/p/sparkstack-registry/ # Submodule. Stacks, recipes, and .state.json files live here.
sparkrun is installed as an editable path dependency (../sparkrun). You can import from it, but for this plan you interact with it exclusively via its CLI (uv run sparkrun ...).
sparkstack-registry is a Git submodule mounted at the project root. Stack directories (stacks/{STACK_NAME}/) are where .state.json files go.
B.2 What NOT to Modify
Do NOT touch
Why
Any file in ~/p/sparkrun/
The -o network=container:... override path has been validated end-to-end through sparkrun's existing code (see Step 2 notes). No sparkrun changes are needed.
sparkstack/cli/_status.py (TUI)
The TUI reads from the local UDS (/tmp/sparkstack.sock). All orchestration events are generated on the head node — the TUI is topology-agnostic.
sparkstack/core/ipc_server.py
Same reason. The IPC protocol doesn't change.
Docker network names
sparkstack-net and vllm-network are created by existing compose files and referenced throughout. Don't rename them.
B.3 Implementation Order (Dependencies)
The phases in Step 2 are strictly ordered. Do not parallelize them:
Step 3: Headscale Server ─── must be running before ──→ Step 2 Phase 1: Sidecars
Step 2 Phase 1: Sidecars ─── must be healthy before ──→ Step 2 Phase 2: IP Resolution
Step 2 Phase 2: IPs ─── must be known before ──→ Step 4: LiteLLM Config Gen
Step 4: LiteLLM Config ─── must be written before ──→ Step 2 Phase 3: Backend Launch
The chicken-and-egg is: LiteLLM config needs Tailnet IPs, but Tailnet IPs come from sidecars, which must be deployed first. The phased approach in Step 2 resolves this.
B.4 Key sparkrun CLI Patterns
All sparkrun interactions use the CLI. Never import sparkrun internals directly.
# Launch a backend on a remote host with sidecar network injection
uv run sparkrun run recipes/qwen.yaml \
--hosts spark --port 8001 --solo --no-follow \
-o network=container:sparkstack-sidecar-main
# Check what's running across the cluster
uv run sparkrun cluster status --cluster mylab --json
# Stop all backends in a cluster (Tier 1 teardown)
uv run sparkrun stop --all --cluster mylab
# Stream logs from a remote backend
uv run sparkrun logs {cluster_id}
The --solo flag is critical — it tells sparkrun to launch the backend as a standalone docker run container (not a compose service). This is what makes the -o network=container:... override work.
B.5 Validated Assumptions (Do Not Re-Research)
These were verified against the source code during design:
sparkrun -o network=container:... — Works end-to-end. coerce_value() preserves the container: prefix as a raw string. Traced through _parse_options() → _apply_recipe_overrides() → ExecutorConfig.from_chain() → DockerExecutor._build_default_opts(). See Step 2 notes.
Headscale v0.28 CLI — Uses headscale users create and headscale preauthkeys create (not the gRPC API from v0.22). See Step 3.
MagicDNS is disabled — All routing uses raw Tailnet IPs. TS_HOSTNAME is set for human readability in tailscale status output only. Do not write code that depends on DNS resolution of Tailscale hostnames.
Head sidecar port exposure is intentional — LiteLLM at localhost:4000 must work for smoke tests, sparkstack wait, and CLI debugging. Don't remove the -p flags.
OpenClaw reaches LiteLLM via Docker DNS — After the sidecar change, the hostname is sparkstack-head-sidecar (not litellm), because LiteLLM shares the sidecar's network namespace and its container name on sparkstack-net is sparkstack-head-sidecar.
B.6 Testing Strategy
Unit tests:uv run pytest tests/ — can be run anytime. No running stack required.
E2E tests:uv run pytest tests/e2e/ — require a running stack. Only run after sparkstack update.
Multi-node e2e: Not yet implemented. The first milestone is a working single-remote-worker deployment tested manually via sparkstack build + sparkstack wait.
Smoke test shortcut: After deployment, curl http://localhost:4000/v1/models verifies LiteLLM is up and routing. curl http://localhost:4000/health checks overall health.
B.7 Appendix A is the Implementation Checklist
Every row in Appendix A with a 🔲 status is a file+line that needs modification. Rows marked ✅ are confirmed correct and need no changes. Use the table as a punch list — when all 🔲 rows are resolved, the feature is complete.
Multi-Node Cluster Support Blueprint
1. Overview & Architecture
The objective of this initiative is to extend
sparkstackfrom a single-node deployment system to a fully distributed orchestration platform. This will allow the core orchestrator, gateway (OpenClaw), and monitoring infrastructure to reside on a lightweight head node (e.g.,pike), while heavy LLM inference backends (e.g., vLLM) are delegated to specialized, GPU-heavy remote worker machines (e.g.,spark,oldbook).1.1 Delegation Model: sparkrun-native
Architectural decision:
sparkstackcontinues to delegate backend container lifecycle tosparkrunviasparkrun run --hosts. It does NOT directly manage remote Docker daemons for backends.sparkstack's new responsibilities are limited to:sparkrunlaunches backends.--networkand environment flags tosparkrunso backends join the Tailscale sidecar's network namespace.This avoids reimplementing sparkrun's model distribution, health checking, container lifecycle, and SSH execution.
1.2 Integration Approach: Shell-Out to sparkrun CLI
Architectural decision:
sparkstackinteracts withsparkrunexclusively via CLI subprocesses (e.g.,uv run sparkrun cluster status --json). It does NOT import sparkrun's internal Python APIs directly.Rationale:
sparkstackalready follows this pattern inlaunch.py(shelling out tosparkrun run).cluster status --jsonandstopare the public, stable interface.Available sparkrun CLI commands for orchestration:
sparkrun cluster status --cluster X --jsonsparkrun cluster check-job {target} --cluster X --jsonsparkrun stop {recipe} --cluster Xsparkrun stop --all --cluster Xsparkrun logs {cluster_id}State management split: Backend state (hosts, ports, container names, health) is queried from sparkrun via these CLI commands at the point of need. Sparkstack maintains its own
.state.jsononly for Tailscale infrastructure that sparkrun has no concept of (see Step 6).Future consideration: Tightening the dependency via direct Python imports (e.g.,
from sparkrun.orchestration.job_metadata import load_job_metadata) is identified as a high-value future refactor. sparkrun is already an editable path dependency, making this a zero-friction change when the API surface stabilizes.1.3 Networking & Security: Headscale + Tailscale
To ensure secure, seamless communication across the cluster, we implement an Encrypted Network Overlay using Headscale (Control Plane) and Tailscale (Data Plane).
v0.28.x, the current stable release) deployed on the head node. Manages node identities, IP allocation, and access controls without external SaaS. MagicDNS is disabled — all routing uses raw Tailnet IPs for simplicity and resilience in automated orchestration.sparkstack-head-sidecar) attached to the local Docker bridge networks. Core routing services (LiteLLM) and monitoring services (Alloy/Prometheus) share this sidecar's network namespace. This ensures zero interference with any pre-existing Tailscale installation on the host OS.tailscale/tailscaleDocker image is deployed as a standalone sidecar container on each worker via SSH. Backend containers (vLLM) join the sidecar's network namespace usingnetwork_mode: container:{sidecar_name}, binding to0.0.0.0:{port}inside the sidecar's network namespace. This makes backends reachable at the sidecar's Tailnet IP without exposing any ports on the worker's physical network.1.4 Architecture Diagram
graph TD classDef headNode fill:#fcfcfc,stroke:#333,stroke-width:2px; classDef workerNode fill:#f0f8ff,stroke:#0066cc,stroke-width:2px; classDef overlay fill:#e6ffe6,stroke:#009933,stroke-width:2px,stroke-dasharray: 5 5; classDef pod fill:#fff,stroke:#999,stroke-width:1px,stroke-dasharray: 2 2; Client["fa:fa-user Client App (e.g. OpenClaw)"] subgraph Head_Node["Head Node (e.g., pike)"] Headscale["fa:fa-server Headscale<br/>(Control Plane)"] subgraph Gateway_Pod["Gateway & Monitoring Pod"] TS_Head["fa:fa-shield-alt Tailscale Sidecar<br/>(100.x.y.1)"] LiteLLM["fa:fa-route LiteLLM Router<br/>network_mode: container:head_sidecar"] Monitoring["fa:fa-chart-bar Alloy / Prometheus<br/>network_mode: container:head_sidecar"] end end subgraph Tailnet["Tailscale Encrypted Overlay (100.x.y.z)"] WireGuard((WireGuard Mesh)) end subgraph Worker_1["Worker Node 1 (e.g., spark)"] subgraph Backend_Pod_1["Backend Deployment"] TSSidecar_W1["fa:fa-shield-alt Tailscale Sidecar<br/>(100.x.y.2)"] vLLM_1["fa:fa-microchip vLLM (Model A)<br/>network_mode: container:sidecar"] end end subgraph Worker_2["Worker Node 2 (e.g., oldbook)"] subgraph Backend_Pod_2["Backend Deployment"] TSSidecar_W2["fa:fa-shield-alt Tailscale Sidecar<br/>(100.x.y.3)"] vLLM_2["fa:fa-microchip vLLM (Model B)<br/>network_mode: container:sidecar"] end end Client -->|LAN: http://head-node:4000| TS_Head LiteLLM -->|http://100.x.y.2:8000/v1| TS_Head LiteLLM -->|http://100.x.y.3:8000/v1| TS_Head Monitoring -->|Scrape via Tailnet| TS_Head TS_Head <-->|Data Plane| WireGuard TSSidecar_W1 <-->|Data Plane| WireGuard TSSidecar_W2 <-->|Data Plane| WireGuard Headscale -.->|Auth & IPs| TS_Head Headscale -.->|Auth & IPs| TSSidecar_W1 Headscale -.->|Auth & IPs| TSSidecar_W2 class Head_Node headNode; class Worker_1,Worker_2 workerNode; class Tailnet overlay; class Backend_Pod_1,Backend_Pod_2,Gateway_Pod pod;2. Telemetry & Observability Pipeline
Monitoring a distributed setup requires a robust, location-agnostic telemetry pipeline.
nv-monitor) to expose native, Prometheus-compatible system and GPU endpoints.sparkstackorchestrator runs entirely on the head node (managing remote nodes via SSH), deployment lifecycle events are generated locally. They are broadcast directly to the local Unix Domain Socket (UDS) (/tmp/sparkstack.sock) without needing to cross the Tailscale network.sparkstack statusCLI reads exclusively from the local UDS. This keeps the user interface entirely agnostic of whether the deployment progress is happening locally or remotely.2.1 Observability Event Flow
sequenceDiagram box LightYellow Head Node participant Scraper as Alloy / Prometheus participant TS_Head as Head Tailscale Sidecar participant Orchestrator as sparkstack (Local) participant UDS as Unix Domain Socket<br/>(/tmp/sparkstack.sock) participant TUI as sparkstack status<br/>(TUI) end box LightGreen Tailscale Overlay participant Mesh as WireGuard Network end box LightBlue Remote Worker Node participant TS_W as Tailscale Sidecar participant Daemon as nv-monitor (Daemon) end Note over Scraper,Daemon: Metrics Scraping (Pull-based) Scraper->>TS_Head: Scrape http://100.x.y.2:9400/metrics TS_Head->>Mesh: Encrypts & Routes Mesh->>TS_W: Delivers Request TS_W->>Daemon: Forward to localhost:9400 Daemon-->>TS_W: Metrics Response TS_W-->>Mesh: Return via Tailnet Mesh-->>TS_Head: Deliver Response TS_Head-->>Scraper: Metrics Data Note over Orchestrator,TUI: Deployment Events (Local only) Orchestrator->>UDS: Emits Deployment Lifecycle Events UDS->>TUI: Live Dashboard UI Updates3. User Experience & Cluster Configuration
To avoid maintaining parallel cluster definitions,
sparkstackwill reusesparkrun's existingClusterManagerinfrastructure for host resolution and SSH configuration.3.1 sparkrun Cluster Reuse
sparkrunalready stores cluster definitions at~/.config/sparkrun/clusters/<name>.yamlvia itsClusterManager. These include hosts, SSH users, cache directories, and transfer mode.sparkstackextends this by maintaining a thin supplementary config at~/.config/sparkstack/clusters/<name>.yamlthat stores only sparkstack-specific metadata not present in sparkrun's schema:Host lists, SSH users, and cache directories come from
sparkrun's cluster definition. This ensures a single source of truth for host inventory.3.2 Target Resolution & CLI
Users can choose to target specific hosts manually using inline overrides, or deploy across an entire saved cluster:
When
--clusteris used,sparkstackreads the sparkrun cluster configuration for host inventory and SSH settings, and its own supplementary config for Tailnet metadata. Host resolution, validation, and SSH connection logic are delegated tosparkrun's existingClusterManager.4. Network Topology Guide
4.1 The Three Networks
sparkstack-net(Local Docker Bridge): Head node orchestration services (OpenClaw, Headscale) reside here.vllm-network(Local Docker Bridge): Shared network for local inference traffic.100.x.y.z): Remote worker nodes communicate exclusively over the encrypted Tailnet. The head node reaches these IPs via itssparkstack-head-sidecarcontainer.Key rule: Remote backends do NOT join
sparkstack-netorvllm-network. They are only reachable via their Tailnet IPs.4.2 Network Planes and Routing Diagram
The following diagram illustrates how the Control Plane (Headscale), Data Plane (Tailscale Overlay), and Application traffic interact across the physical network and Docker bridges:
graph TD classDef lan fill:#e1f5fe,stroke:#01579b,stroke-width:2px,stroke-dasharray: 4 4; classDef tailnet fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; classDef node fill:#fff,stroke:#333,stroke-width:1px; subgraph LAN ["Physical LAN (e.g., 192.168.1.x)"] direction LR subgraph Head_Node ["Head Node (192.168.1.100)"] Headscale["fa:fa-server Headscale<br/>(Control Plane)"] OpenClaw["fa:fa-brain OpenClaw<br/>(sparkstack-net)"] subgraph Head_Sidecar ["sparkstack-head-sidecar (100.x.y.1)"] LiteLLM["LiteLLM Router"] Alloy["Alloy Scraper"] end end subgraph Worker_Node ["Worker Node (192.168.1.101)"] subgraph Worker_Sidecar ["sparkstack-sidecar-worker (100.x.y.2)"] vLLM["vLLM Backend"] end end end %% Control Plane Flow Worker_Sidecar -.->|"1. Control Plane (HTTP:8080 over LAN)<br>Auth & Key Exchange"| Headscale Head_Sidecar -.->|"1. Control Plane"| Headscale %% Data Plane Flow Head_Sidecar ===>|"2. Data Plane (WireGuard UDP)<br>Encrypted Overlay (100.x.y.z)"| Worker_Sidecar %% Application Flow OpenClaw -->|"3a. App Request (Docker Bridge)"| LiteLLM LiteLLM -->|"3b. App Request (over Tailnet)"| vLLM Alloy -->|"3c. Metrics Scrape (over Tailnet)"| vLLM class LAN lan; class Head_Node,Worker_Node node; class Head_Sidecar,Worker_Sidecar tailnet;4.3 Head Node Configuration (Headscale)
sparkstack-net. It must expose a port to the host (e.g.,ports: ["8080:8080"]).SPARKSTACK_HEADSCALE_SERVERmust be set to the head node's routable LAN IP or DNS name. ThesparkstackCLI will auto-detect the host's primary LAN IP, but users can override this.config.yamlis stored inservices/headscale/config/, configured to reject open registrations. MagicDNS is disabled (dns.magic_dns: false) — all service routing uses raw Tailnet IPs, avoiding DNS resolution dependencies in the automation path. Hostnames are still set viaTS_HOSTNAMEfor human-readabletailscale statusoutput, but they are not used for routing.sparkstack setup, the CLI generates a persistent Headscale pre-auth key and saves it to.envasSPARKSTACK_HEADSCALE_AUTH_KEY.4.4 Head Node Configuration (Tailscale Sidecar)
To achieve 100% isolation from any existing Tailscale network on the head node, we deploy a Head Node Sidecar instead of installing Tailscale on the host.
tailscale/tailscalecontainer namedsparkstack-head-sidecaris deployed on the head node.sparkstack-netandvllm-network.sparkstack-head-sidecarMUST expose their required ports to the host and bridge networks (e.g.,-p 4000:4000for LiteLLM,-p 4318:4318for OTLP,-p 9090:9090for Prometheus). This host-port exposure is intentional — it enables local CLI tools (smoke tests,curldebugging,sparkstack wait) to reach LiteLLM atlocalhost:4000without routing through Docker DNS.SPARKSTACK_HEADSCALE_AUTH_KEYas remote workers.4.5 Remote Node Configuration (Tailscale Sidecars)
tailscale/tailscalecontainer on the remote host via SSH +docker run. This sidecar runs independently — it is NOT part of the backend's compose file.network_mode: container:{sidecar_name}, binding to0.0.0.0:{port}inside the sidecar's namespace.SPARKSTACK_HEADSCALE_AUTH_KEYinjected at deploy time.TS_STATE_DIR=/var/lib/tailscale(explicit, matching the volume mount)TS_ACCEPT_DNS=false(prevents sidecar from overriding container DNS)TS_HOSTNAME=sparkstack-{role}-{host}(human-readable identifier intailscale status, not used for routing)tailscale status --jsonto verify mesh connection before backend launch4.6 Gateway Routing (Head Node → Remote Backends)
LiteLLM and Monitoring share the Head Node Sidecar.
Because we avoid installing Tailscale on the host, head node services that need to route to the Tailnet must do so via the
sparkstack-head-sidecar:network_mode: container:sparkstack-head-sidecar.sparkstack-head-sidecarcontainer is attached tosparkstack-netandvllm-network.sparkstack-net(like OpenClaw) can reach LiteLLM by callinghttp://sparkstack-head-sidecar:4000(since LiteLLM shares the sidecar's network namespace).backend_url=http://{worker_tailnet_ip}:{port}/v1.http://main_solo:8000/v1.This achieves total network isolation without requiring any host-level software dependencies on the head node.
5. Detailed Implementation Steps
Step 1: Core Builder Context Extraction
Target File:
sparkstack/core/builders/stack.pyTarget File:
sparkstack/core/schemas.pytargetfield (hostname from sparkrun's cluster definition)._process_model_request()to parse thetargetparameter from the model override dictionary.target_hostinto thecontextdictionary passed to all service handlers. This ensures all downstream builders (Docker, LiteLLM, monitoring) are aware of the deployment destination.is_remoteboolean derived fromtarget_host != "localhost". This flag drives all conditional logic below.Step 2: Two-Phase Remote Infrastructure Deployment
Target File:
sparkstack/manager/launch.py(new helper:sparkstack/manager/remote.py)This step deploys Tailscale sidecars on remote workers before sparkrun launches any backends. This resolves the IP resolution chicken-and-egg: sidecar Tailnet IPs are known before LiteLLM config generation.
Phase 1: Deploy Tailscale sidecars (Head + Workers)
First, deploy the head node sidecar (
sparkstack-head-sidecar). It binds required gateway ports:docker run -d \ --name sparkstack-head-sidecar \ --cap-add NET_ADMIN,NET_RAW \ --network sparkstack-net \ --restart unless-stopped \ -p 4000:4000 -p 4318:4318 -p 9090:9090 \ -v sparkstack-ts-state-head:/var/lib/tailscale \ -e TS_AUTHKEY={auth_key} \ -e TS_STATE_DIR=/var/lib/tailscale \ -e TS_ACCEPT_DNS=false \ -e TS_EXTRA_ARGS="--login-server=http://{headscale_server}" \ tailscale/tailscale:{pinned_version} # Also attach to vllm-network docker network connect vllm-network sparkstack-head-sidecarThen, for each unique remote
target_host:ClusterManagerfor credentials).ssh user@{host} docker run -d \ --name sparkstack-sidecar-{role} \ --cap-add NET_ADMIN,NET_RAW \ -v sparkstack-ts-state-{role}:/var/lib/tailscale \ -e TS_AUTHKEY={auth_key} \ -e TS_EXTRA_ARGS="--login-server=http://{headscale_server}" \ -e TS_HOSTNAME=sparkstack-{role}-{host} \ -e TS_STATE_DIR=/var/lib/tailscale \ -e TS_ACCEPT_DNS=false \ --restart unless-stopped \ tailscale/tailscale:{pinned_version}Phase 2: Resolve Tailnet IPs
After all sidecars are healthy:
ssh user@{host} docker exec sparkstack-sidecar-{role} tailscale ip -4{role} → {tailnet_ip}in the build context for use by LiteLLM config generation (Step 5).Phase 3: Launch backends via sparkrun
For each backend,
launch_stack()callssparkrun runwith modified flags based on locality:Key change: Remote backends use
network=container:sparkstack-sidecar-{role}instead ofnetwork=sparkstack-net. This makes the backend share the sidecar's network namespace, binding to0.0.0.0:{port}inside it. The backend becomes reachable at the sidecar's Tailnet IP. The--soloflag remains compatible becausesparkrun run --solopasses-o network=...directly todocker run --network=....Validated override path (Phase 0 complete): The
-o network=container:...override has been confirmed to work end-to-end through sparkrun's existing code:_parse_options()parsesnetwork=container:sparkstack-sidecar-maininto a dict (coerce_valuecorrectly preserves thecontainer:prefix as a raw string)_apply_recipe_overrides()merges the override into the recipe config chainExecutorConfig.from_chain()resolves thenetworkfieldDockerExecutor._build_default_opts()emits--network=container:sparkstack-sidecar-mainNo sparkrun code changes are required for this integration point.
Step 3: Headscale Server Deployment & Auto-Provisioning
Target File:
sparkstack/core/config.pyTarget File:
services/headscale/docker-compose.ymlDeployment: Headscale will be deployed as a foundational core service on the head node, initialized alongside other orchestration services on
sparkstack-net.Version Pinning: Pin to
headscale/headscale:0.28.0(current stable as of 2026-05). The Headscale API surface changed significantly between 0.22→0.23 (gRPC replaced with REST). v0.28 uses theheadscaleCLI for pre-auth key generation:State Persistence: Mount a persistent Docker volume (
headscale-data:/var/lib/headscale) to ensure node identities, cryptographic keys, and overlay IP assignments survive container restarts.User Management: Headscale v0.28 does not auto-create users. The setup flow must explicitly create and manage the
sparkstacknamespace user before generating pre-auth keys. User creation is idempotent — re-running on an existing user is a no-op error that the setup script handles gracefully.Auth Flow & Provisioning:
sparkstack setup, the CLI starts Headscale and waits for it to become healthy.docker exec sparkstack-headscale headscale preauthkeys create \ --user sparkstack --reusable --expiration 87600hSPARKSTACK_HEADSCALE_AUTH_KEYin.env.SPARKSTACK_HEAD_TAILNET_IPin.env.SPARKSTACK_HEADSCALE_SERVERis auto-detected from the host's primary LAN IP (user-overridable in.env).Key lifecycle: The pre-auth key has a 10-year expiration (
87600h). If the key is rotated or expires, re-runsparkstack setupto regenerate. All existing sidecars remain authenticated — only new sidecar enrollments require a valid key.Step 4: Service Handler & Gateway Routing Configuration
Target File:
sparkstack/core/handlers/sparkrun.pyTarget File:
sparkstack/core/builders/stack.pybackenddictionary returned byapply_to_builders()to include"target": self.context["target_host"]and"is_remote": self.context["is_remote"].is_remote:backend_url = f"http://{tailnet_ip_map[role]}:{port}/v1"— uses the Tailnet IP resolved in Step 2, Phase 2.backend_url = f"http://{container_hostname}:{port}/v1"— unchanged from today, uses Docker DNS onsparkstack-net.litellmandmonitoringCompose builders to outputnetwork_mode: container:sparkstack-head-sidecarinstead of traditionalnetworksblocks.sparkstack-nethosts transparently.OTEL_EXPORTER_OTLP_ENDPOINTenvironment variable is overridden tohttp://{SPARKSTACK_HEAD_TAILNET_IP}:4318(read from.env, persisted at setup time). Local backends continue usinghttp://alloy:4318via Docker DNS.Step 5: Per-Host Resource Accounting
Target File:
sparkstack/core/builders/stack.py(_check_constraints())Target File:
sparkstack/core/env.pyThe current
_check_constraints()sums VRAM and RAM across all backends and checks against a single host's ceiling. With multi-node, resource budgets must be per-host._check_constraints()groups backends bytarget_hostand validates each group against that host's resource limits. If no per-host override exists, fall back to the current global defaults fromenv.py.Step 6: State Tracking & Two-Tier Orphan Teardown
Target File:
sparkstack/manager/remote.py(new)6.1 State Management Principle
This avoids state duplication. Backend metadata (hosts, ports, container names, health) lives in sparkrun's existing state system (
~/.cache/sparkrun/jobs/,sparkrun cluster status). Sparkstack maintains a lightweight file tracking only Tailscale-sidecar-specific metadata.6.2 Sidecar State File
State File:
sparkstack-registry/stacks/{STACK_NAME}/.state.json{ "deployed_at": "2026-05-10T17:00:00Z", "cluster_name": "mylab", "sidecars": { "spark": { "container_name": "sparkstack-sidecar-main", "tailnet_ip": "100.64.0.2", "role": "main" }, "oldbook": { "container_name": "sparkstack-sidecar-secondary", "tailnet_ip": "100.64.0.3", "role": "secondary" } } }The
cluster_namefield is required for teardown resolution —set_current.pyreceives astack_nameandstack_pathbut has no way to derive the sparkrun cluster name without this mapping.This file contains only data that sparkrun has no concept of:
It does NOT contain backend container names, recipe info, ports, or any data already tracked by sparkrun.
Write on deploy: After Phase 2 (IP resolution) succeeds, write/update the sidecar state file. This happens before sparkrun launches backends.
6.3 Two-Tier Orphan Teardown
Teardown is split into two tiers matching the authority model:
Tier 1: Backend teardown (delegated to sparkrun)
Before deploying a new stack version, sparkstack delegates backend cleanup to sparkrun via CLI:
This ensures sparkrun's internal metadata (job files, pending ops) stays synchronized. Never directly
docker rm -fsparkrun-managed containers — that would leave orphaned metadata in~/.cache/sparkrun/jobs/.Tier 2: Sidecar teardown (managed by sparkstack)
After backends are stopped, sparkstack handles sidecar cleanup using
.state.json:ssh user@{host} docker rm -f {sidecar_container_name}.state.json.Partial failure handling: If sidecar deployment succeeds but backend launch fails, the sidecar is left running (idempotent; next deploy reuses it). Sidecar state is written immediately after sidecar health confirmation, independent of backend launch success.
Step 7: Error Handling & Resilience
/tmp/sparkstack.sock), rather than hanging indefinitely.healthcheckfor use withdepends_onin future compose-based approaches:Step 8: Monitoring, Health Probes & Discovery Updates
The following subsystems hardcode
localhostor assume local Docker access for health checks, service discovery, and teardown. They require conditional logic based on backend locality.8.1 Monitoring Builder (
sparkstack/core/builders/monitoring.py)Current assumption:
add_target()registers scrape targets using local Docker DNS names (e.g.,main_solo:8001). Prometheus on the head node can only resolve these for local containers.Required change:
add_target()must register the Tailnet IP instead of the Docker hostname:{tailnet_ip}:{port}rather than{container_hostname}:{port}.SparkrunServiceHandler.apply_to_builders()must pass the resolved Tailnet IP into the monitoring builder whenis_remoteis true.Cross-ref: Step 4 (Service Handler & Gateway Routing) must be extended to cover this.
8.2 Wait-for-Backends Health Probe (
sparkstack/manager/wait_for_backends.py)Current assumptions:
http://localhost:8126/status— assumes the sparkrun progress manager runs on the head node. This remains correct because sparkrun's progress manager aggregates status centrally, but must be validated for remote backends.docker logs --tail 20 {container}— runs against the local Docker daemon. For remote backends, the container does not exist locally.http://localhost:{litellm_port}/v1/...— smoke tests run through LiteLLM on the head node. This remains correct regardless of backend location (LiteLLM proxies to remote backends).Required changes:
ssh user@{host} docker logs --tail 20 {container}instead of localdocker logs. The backend'starget_hostmust be available in the service discovery data (see 8.3).8.3 Service Discovery (
sparkstack/core/discovery.py)Current assumptions:
get_container_name_by_port()(line 21): Runsdocker psanddocker execagainst the local Docker daemon to discover which container owns a port. This is meaningless for remote backends whose containers exist on a different host.get_active_services()(line 70): Parseslitellm-config.yamlto discover backends by API base URL. The existing logic at line 114 already handles non-localhost hostnames by using the hostname as the container name — this partially works for Tailnet IPs, but the returnedcontainerfield will be an IP address instead of a Docker container name, which may confuse downstream callers.Required changes:
get_active_services()should be extended to enrich service entries with three new fields:target_host: SSH-reachable hostname (e.g.,spark)is_remote: boolean flagtailnet_ip: Tailnet IP for routing (from.state.json)container: Docker container name (fromsparkrun cluster status --json)uv run sparkrun cluster status --cluster {name} --json. For Tailnet IP mapping, read.state.json.wait_for_backends.pyto usessh user@{target_host} docker logs --tail 20 {container}for remote crash log retrieval.get_container_name_by_port()should short-circuit and returnNonefor remote backends (the caller already handles this gracefully).8.4 Services Manager Health Probes (
sparkstack/manager/services.py)Current assumptions:
HttpProbe("http://localhost:4000/health")— correct; LiteLLM always runs locally on the head node.HttpProbe("http://localhost:8126/status")— correct; the progress manager runs locally.No changes needed. Both probed services are head-node-local.
8.5 Set-Current Teardown (
sparkstack/manager/set_current.py)Current assumptions:
docker ps -a -q -f name=sparkrun|vllm|...followed bydocker rm -f— only kills containers on the local Docker daemon. Remote backend containers are untouched.Required changes:
set_current.pymust invoke the two-tier teardown from Step 6:sparkrun stop --all --cluster {name}to let sparkrun handle backend container teardown and its own metadata cleanup..state.jsonand SSH-remove sidecar containers from hosts no longer needed.docker psfilter remains for local containers.8.6 Monitor CLI (
sparkstack/cli/_monitor.py)Current assumptions:
http://localhost:8126/status— progress manager polling.http://localhost:18789/healthz— another local health endpoint.No changes needed. These are head-node-local services.
8.7 SparkrunServiceHandler Hardcoded Target (
sparkstack/core/handlers/sparkrun.py)Current assumption (line 127):
"target": "localhost"is hardcoded in the backend dict.Required change: Already addressed in Step 1 + Step 4 — the
targetfield will be set fromself.context["target_host"]instead of hardcoded"localhost".8.8 OTEL Endpoint Hardcoding (
sparkstack/core/handlers/sparkrun.py)Current assumptions (lines 161-167):
OTEL_EXPORTER_OTLP_ENDPOINT→http://alloy:4318otlp_traces_endpoint→http://alloy:4318/v1/tracesThese use Docker DNS names that only resolve on the head node's
sparkstack-netbridge.Required change: Already addressed in Step 4 — for remote backends, these are overridden to
http://{SPARKSTACK_HEAD_TAILNET_IP}:4318. The handler sets them at build time;launch.pyreinforces them at launch time via-o env.OTEL_EXPORTER_OTLP_ENDPOINT=....8.9 LiteLLM Builder Backend URLs (
sparkstack/core/builders/litellm.py)Current assumption:
backend_urlis constructed fromcontainer_hostname(Docker DNS), which only resolves locally.Required change: Already addressed in Step 4 — for remote backends,
backend_urluses the resolved Tailnet IP instead of the Docker hostname.8.10 Global Resource Constants (
sparkstack/core/env.py)Current assumptions:
USABLE_SPARK_MEMORY_GB = 121.0— hardcoded for a single DGX Spark.MAX_DOCKER_MEMORY_GB— derived from the single-node pool.MAX_VRAM_UTILIZATION = 0.95— single GPU pool ceiling.Required change: Already addressed in Step 5 — per-host resource overrides in the cluster supplementary config supersede these globals. The globals become defaults for hosts without explicit overrides.
Appendix A: Single-Node Assumptions Summary Table
core/schemas.pyModelRequesthas notargetfieldcore/env.pyMAX_DOCKER_MEMORY_GB,MAX_VRAM_UTILIZATIONcore/builders/stack.py_check_constraints()sums globallycore/builders/stack.py_process_model_request()has no target parsingcore/handlers/sparkrun.py"target": "localhost"hardcodedcore/handlers/sparkrun.pyalloy:4318)core/handlers/sparkrun.pycore/handlers/sparkrun.pybackend_urlusescontainer_hostname(Docker DNS)core/builders/monitoring.pyadd_target()uses Docker DNS names for scrape targetscore/builders/litellm.pybackend_urlparam assumed to be Docker DNScore/discovery.pyget_container_name_by_port()uses localdocker ps/execcore/discovery.pyget_active_services()no remote metadata; needs sparkrun CLI +.state.jsonmanager/launch.pydocker rmonlymanager/launch.pysparkstack-netfor all backendsmanager/launch.pybackend.get("target", "localhost")fallbackmanager/wait_for_backends.pylocalhost:8126manager/wait_for_backends.pydocker logsagainst local daemon for crash logsmanager/wait_for_backends.pylocalhost:{litellm_port}manager/services.pylocalhost:4000manager/services.pylocalhost:8126manager/set_current.pydocker ps/rmonlymanager/set_current.pycli/_monitor.pylocalhostcore/handlers/gateway.pynetwork_mode: container:sparkstack-head-sidecarLITELLM_BASE_URLmust change fromhttp://litellm:4000tohttp://sparkstack-head-sidecar:4000(LiteLLM now shares sidecar namespace, not its own container name)Appendix B: Implementation Briefing for Agents
B.1 Repository Layout
sparkrunis installed as an editable path dependency (../sparkrun). You can import from it, but for this plan you interact with it exclusively via its CLI (uv run sparkrun ...).sparkstack-registryis a Git submodule mounted at the project root. Stack directories (stacks/{STACK_NAME}/) are where.state.jsonfiles go.B.2 What NOT to Modify
~/p/sparkrun/-o network=container:...override path has been validated end-to-end through sparkrun's existing code (see Step 2 notes). No sparkrun changes are needed.sparkstack/cli/_status.py(TUI)/tmp/sparkstack.sock). All orchestration events are generated on the head node — the TUI is topology-agnostic.sparkstack/core/ipc_server.pysparkstack-netandvllm-networkare created by existing compose files and referenced throughout. Don't rename them.B.3 Implementation Order (Dependencies)
The phases in Step 2 are strictly ordered. Do not parallelize them:
The chicken-and-egg is: LiteLLM config needs Tailnet IPs, but Tailnet IPs come from sidecars, which must be deployed first. The phased approach in Step 2 resolves this.
B.4 Key sparkrun CLI Patterns
All sparkrun interactions use the CLI. Never import sparkrun internals directly.
The
--soloflag is critical — it tells sparkrun to launch the backend as a standalonedocker runcontainer (not a compose service). This is what makes the-o network=container:...override work.B.5 Validated Assumptions (Do Not Re-Research)
These were verified against the source code during design:
sparkrun
-o network=container:...— Works end-to-end.coerce_value()preserves thecontainer:prefix as a raw string. Traced through_parse_options()→_apply_recipe_overrides()→ExecutorConfig.from_chain()→DockerExecutor._build_default_opts(). See Step 2 notes.Headscale v0.28 CLI — Uses
headscale users createandheadscale preauthkeys create(not the gRPC API from v0.22). See Step 3.MagicDNS is disabled — All routing uses raw Tailnet IPs.
TS_HOSTNAMEis set for human readability intailscale statusoutput only. Do not write code that depends on DNS resolution of Tailscale hostnames.Head sidecar port exposure is intentional — LiteLLM at
localhost:4000must work for smoke tests,sparkstack wait, and CLI debugging. Don't remove the-pflags.OpenClaw reaches LiteLLM via Docker DNS — After the sidecar change, the hostname is
sparkstack-head-sidecar(notlitellm), because LiteLLM shares the sidecar's network namespace and its container name onsparkstack-netissparkstack-head-sidecar.B.6 Testing Strategy
uv run pytest tests/— can be run anytime. No running stack required.uv run pytest tests/e2e/— require a running stack. Only run aftersparkstack update.sparkstack build+sparkstack wait.curl http://localhost:4000/v1/modelsverifies LiteLLM is up and routing.curl http://localhost:4000/healthchecks overall health.B.7 Appendix A is the Implementation Checklist
Every row in Appendix A with a 🔲 status is a file+line that needs modification. Rows marked ✅ are confirmed correct and need no changes. Use the table as a punch list — when all 🔲 rows are resolved, the feature is complete.
B.8 Suggested Phasing for Implementation
tailscale statuson all sidecars shows mesh connectivitycurl http://localhost:4000/v1/modelsreturns remote model.state.jsonwritten, two-tier teardown works,set_currenthandles remote cleanupEach phase produces a testable artifact. Don't move to the next phase until the current one is verified.