Nimbus is a standalone Elixir daemon for provisioning and managing elastic CI runners. It provides standalone value as an open-source tool while creating a pathway to Tuist's managed offerings (similar to Grafana Cloud model).
Standalone Value Proposition: "Elastic CI runners for any Git forge, using your cloud provider"
| Stage | Deployment | What Tuist Provides | User Value |
|---|---|---|---|
| Stage 1: Discovery | Self-hosted daemon | OSS tool, docs | Cost savings + cloud credit usage |
| Stage 2: Simplification | Tuist-hosted daemon + control plane | Managed daemon, UI, monitoring | Less ops burden |
| Stage 3: Full Managed | Tuist infrastructure | Complete runner infrastructure | Zero-config convenience |
| Stage 4: Premium | Tuist platform | Runners + caching + insights | Full build optimization |
┌─────────────────────────────────────────────────────────┐
│ Tuist Control Plane (Optional) │
│ - Multi-tenant daemon orchestration │
│ - Billing/metering │
│ - UI dashboard │
│ - Daemon hibernation (inactive → $0.001/month) │
│ - Optimization recommendations │
└─────────────────┬───────────────────────────────────────┘
│ HTTP API
┌─────────────────▼───────────────────────────────────────┐
│ Nimbus Daemon (per tenant, isolated) │
│ - Git forge integration (GitHub, GitLab, Forgejo) │
│ - Cloud provider integration (AWS, GCP, Hetzner) │
│ - Runner lifecycle management │
│ - Job queue management │
│ - Health monitoring & telemetry │
│ - RESTful API for control plane │
│ - Per-daemon storage (SQLite or provided DB) │
└─────────────────┬───────────────────────────────────────┘
│ Provisions
┌─────────────────▼───────────────────────────────────────┐
│ Cloud Provider (AWS/GCP/Hetzner/etc) │
│ - EC2/Compute Engine instances │
│ - Ephemeral environments │
│ - Auto-cleanup │
└─────────────────────────────────────────────────────────┘
Per-Tenant Daemon Cost (EC2 bin packing on t4g.large):
- Active daemon (10% of tenants): ~$0.60/month
- Hibernated daemon (90% of tenants): ~$0.001/month (state in S3)
- Blended cost: ~$0.10/tenant/month
Pricing: $10/month per tenant → ~95% gross margin
Hibernation Strategy:
- Daemon hibernates after 15 minutes of inactivity
- State saved to S3 (~$0.001/month storage)
- Cold start on job arrival: 1-2 seconds
- 50 daemons per t4g.large instance ($48/month)
- Standalone Daemon: Each tenant gets an isolated Elixir process/daemon
- Per-Daemon Storage: SQLite by default, or configurable storage connection
- Unified Machine Model: Abstract away provider-specific details (e.g., AWS dedicated hosts)
- Telemetry-driven: Emit events for all significant operations
- Lean State: Minimize stored data - prefer querying cloud provider APIs with tags
- API-First: RESTful API for control plane integration and management
- Hibernation Support: Daemons can hibernate and restore state for cost efficiency
Nimbus
├── Nimbus.Application (OTP app, supervision tree)
├── Nimbus.Daemon (per-tenant daemon logic)
│ ├── Nimbus.Daemon.Supervisor (per-daemon supervision tree)
│ ├── Nimbus.Daemon.State (hibernation/restore)
│ └── Nimbus.Daemon.Config (per-daemon configuration)
├── Nimbus.API (HTTP API for control plane)
│ ├── Nimbus.API.Router (endpoint routing)
│ ├── Nimbus.API.Health (health checks)
│ └── Nimbus.API.Machines (machine management)
├── Nimbus.Storage (per-daemon storage)
│ ├── Nimbus.Storage.SQLite (default implementation)
│ └── Nimbus.Storage.Postgres (optional for larger deployments)
├── Nimbus.Provider (behavior for cloud providers)
│ ├── Nimbus.Provider.AWS (Phase 1)
│ ├── Nimbus.Provider.Hetzner (Future)
│ ├── Nimbus.Provider.GCP (Future)
│ └── Nimbus.Provider.Local (testing/development)
├── Nimbus.Forge (Git forge integrations)
│ ├── Nimbus.Forge.GitHub (Phase 1)
│ ├── Nimbus.Forge.GitLab (Future)
│ └── Nimbus.Forge.Forgejo (Future)
├── Nimbus.Machine
│ ├── Nimbus.Machine.Provisioner
│ ├── Nimbus.Machine.Lifecycle
│ └── Nimbus.Machine.SSH
├── Nimbus.Telemetry (instrumentation)
└── Nimbus.CLI (command-line interface for self-hosted)
Note: Each daemon represents a single tenant, so tenant_id is a daemon-level configuration rather than a data model.
# Loaded at daemon startup from config file or environment
%Nimbus.Daemon.Config{
daemon_id: "daemon-uuid",
tenant_id: "tenant-123", # For control plane tracking
tenant_name: "Acme Corp",
storage: %{
type: :sqlite, # or :postgres
path: "/var/lib/nimbus/nimbus.db" # or connection string
},
api: %{
port: 4000,
host: "0.0.0.0",
secret_key_base: "..."
}
}# Stored in per-daemon database, can have multiple providers
%ProviderConfig{
id: "provider-456",
name: "AWS US-East",
type: :aws, # :aws, :hetzner, :gcp, :azure
credentials: %{
access_key_id: "AKIA...",
secret_access_key: "..."
},
config: %{
region: "us-east-1",
tags: %{"managed_by" => "nimbus", "tenant" => "tenant-123"}
}
}# Stored in per-daemon database, typically one per daemon
%ForgeConfig{
id: "forge-789",
type: :github, # :github, :gitlab, :forgejo
credentials: %{
# GitHub App
app_id: "123456",
installation_id: "789012",
private_key: "-----BEGIN RSA PRIVATE KEY-----..."
# Or for GitLab/Forgejo
# token: "glpat-..."
},
org: "tuist", # or group/instance URL for GitLab/Forgejo
webhook_secret: "..."
}# Stored in per-daemon database
%Machine{
id: "machine-uuid",
provider_id: "provider-456",
# Unified fields
os: :macos, # :macos, :linux
arch: :arm64, # :arm64, :x86_64
state: :ready, # :provisioning, :image_installing, :ready, :running, :stopping, :terminated
ip_address: "1.2.3.4",
ssh_public_key: "ssh-rsa ...",
labels: ["macos", "xcode-15"],
# Image configuration (optional - nil for machines without images)
image: %{
id: "ami-123abc", # AMI ID, Docker image name, etc.
type: :ami, # :ami | :docker | nil
state: :ready, # :provisioning | :ready
installed_at: ~U[2025-01-15 10:30:00Z]
},
# Timestamps (queried from provider, not stored)
created_at: ~U[2025-01-15 10:00:00Z],
# Provider-specific metadata
provider_metadata: %{
instance_id: "i-123abc",
host_id: "h-456def", # AWS Mac only
minimum_allocation_hours: 24 # Provider-specific
}
}Notes:
- Machine
stateincludes:image_installingto track image setup progress imagefield tracks software configuration (AMIs for macOS, Docker for Linux)- Linux machines with pre-built images transition directly to
:ready - macOS machines go through
:provisioning→:image_installing→:ready - Future: May extend to support multiple VMs per physical machine (2 VMs/Mac limit)
Each daemon has its own storage (SQLite by default, Postgres optional):
defmodule Nimbus.Storage do
@callback list_providers() :: {:ok, [ProviderConfig.t()]} | {:error, term()}
@callback get_provider(provider_id :: String.t()) :: {:ok, ProviderConfig.t()} | {:error, :not_found}
@callback create_provider(attrs :: map()) :: {:ok, ProviderConfig.t()} | {:error, term()}
@callback update_provider(provider_id :: String.t(), attrs :: map()) :: {:ok, ProviderConfig.t()} | {:error, term()}
@callback delete_provider(provider_id :: String.t()) :: :ok | {:error, term()}
@callback get_forge_config() :: {:ok, ForgeConfig.t()} | {:error, :not_found}
@callback create_forge_config(attrs :: map()) :: {:ok, ForgeConfig.t()} | {:error, term()}
@callback update_forge_config(attrs :: map()) :: {:ok, ForgeConfig.t()} | {:error, term()}
@callback list_machines() :: {:ok, [Machine.t()]} | {:error, term()}
@callback get_machine(machine_id :: String.t()) :: {:ok, Machine.t()} | {:error, :not_found}
@callback create_machine(attrs :: map()) :: {:ok, Machine.t()} | {:error, term()}
@callback update_machine(machine_id :: String.t(), attrs :: map()) :: {:ok, Machine.t()} | {:error, term()}
@callback delete_machine(machine_id :: String.t()) :: :ok | {:error, term()}
endEach cloud provider implements:
defmodule Nimbus.Provider do
@callback provision(provider_config :: ProviderConfig.t(), specs :: map()) ::
{:ok, Machine.t()} | {:error, term()}
@callback terminate(provider_config :: ProviderConfig.t(), machine :: Machine.t()) ::
:ok | {:error, term()}
@callback can_terminate?(machine :: Machine.t()) ::
{:ok, true} | {:error, :minimum_allocation_period, hours_remaining: integer()}
@callback list_machines(provider_config :: ProviderConfig.t()) ::
{:ok, [Machine.t()]} | {:error, term()}
@callback get_machine(provider_config :: ProviderConfig.t(), machine_id :: String.t()) ::
{:ok, Machine.t()} | {:error, term()}
endThe daemon exposes a RESTful API for control plane integration and management:
# Health & Status
GET /health → Daemon health check
GET /status → Daemon status (active/hibernated, uptime, etc.)
# Configuration (read-only in production, writable in dev/testing)
GET /config → Daemon configuration
GET /providers → List configured providers
GET /forge → Forge configuration (credentials masked)
# Machine Management
POST /machines → Provision new machine
GET /machines → List all machines
GET /machines/:id → Get machine details
DELETE /machines/:id → Terminate machine
# Hibernation (for control plane)
POST /hibernate → Hibernate daemon (save state, shutdown)
POST /restore → Restore from hibernation
# Telemetry
GET /metrics → Prometheus-compatible metrics
1. Request machine via HTTP API or CLI
POST /machines with specs (os, arch, provider_id, labels)
├─> Validate provider exists in daemon storage
├─> Get provider credentials from daemon storage
└─> Call Provider.provision/2
2. Provider provisions infrastructure
AWS Mac: Allocate dedicated host → Launch instance
AWS Linux: Launch instance
Hetzner: Create server
├─> Tag with daemon_id for discovery
└─> Return Machine struct
3. Machine.Lifecycle sets up runner
├─> Wait for machine to be accessible
├─> SSH into machine
├─> Install dependencies (homebrew, etc.)
├─> Download Git forge runner agent
├─> Get registration token from Forge API
├─> Register runner with forge
├─> Initialize machine state file (SQLite on machine)
└─> Emit telemetry: [:nimbus, :machine, :ready]
4. Runner operates (managed by Git forge)
├─> Forge assigns jobs
└─> Runner executes jobs
5. Request termination via HTTP API or CLI
DELETE /machines/:id
├─> Check can_terminate? (24h minimum for AWS Mac)
├─> Unregister runner from forge
├─> Terminate instance (and release host if needed)
├─> Remove from daemon storage
└─> Emit telemetry: [:nimbus, :machine, :terminated]
Nimbus emits telemetry events for all significant operations:
# Machine lifecycle
[:nimbus, :machine, :provision_start]
[:nimbus, :machine, :provision_success]
[:nimbus, :machine, :provision_failure]
[:nimbus, :machine, :setup_start]
[:nimbus, :machine, :setup_success]
[:nimbus, :machine, :setup_failure]
[:nimbus, :machine, :ready]
[:nimbus, :machine, :terminate_start]
[:nimbus, :machine, :terminate_success]
[:nimbus, :machine, :terminate_failure]
# Forge operations
[:nimbus, :forge, :register_runner_start]
[:nimbus, :forge, :register_runner_success]
[:nimbus, :forge, :register_runner_failure]
[:nimbus, :forge, :unregister_runner_start]
[:nimbus, :forge, :unregister_runner_success]
[:nimbus, :forge, :unregister_runner_failure]
# Cloud provider operations
[:nimbus, :cloud_provider, :api_call_start]
[:nimbus, :cloud_provider, :api_call_success]
[:nimbus, :cloud_provider, :api_call_failure]
# SSH operations
[:nimbus, :ssh, :connect_start]
[:nimbus, :ssh, :connect_success]
[:nimbus, :ssh, :connect_failure]
[:nimbus, :ssh, :command_start]
[:nimbus, :ssh, :command_success]
[:nimbus, :ssh, :command_failure]Each event includes metadata like tenant_id, machine_id, duration, error, etc.
For standalone/self-hosted deployments:
# Installation
brew install tuist/tap/nimbus
# or
curl -sSL https://get.nimbus.dev | sh
# Initialize daemon configuration
nimbus init
# Creates ~/.nimbus/config.toml with prompts for:
# - Daemon ID/name
# - Storage type (sqlite/postgres)
# - API port
# - Cloud provider credentials
# - Git forge configuration
# Start daemon
nimbus start
# Starts daemon on configured port (default: 4000)
# Logs to ~/.nimbus/logs/
# State in ~/.nimbus/nimbus.db (SQLite)
# Manage providers
nimbus provider add aws --name "AWS US-East" --region us-east-1
nimbus provider list
nimbus provider remove <id>
# Manage machines
nimbus machine provision --provider aws --os macos --arch arm64
nimbus machine list
nimbus machine get <id>
nimbus machine terminate <id>
# Daemon management
nimbus status # Check daemon health
nimbus logs # View daemon logs
nimbus stop # Stop daemon gracefully
nimbus restart # Restart daemonDeployment Model: Self-hosted standalone daemon Cloud Provider: AWS EC2 Mac (mac2.metal on dedicated hosts) Git Forge: GitHub (via GitHub App) Storage: SQLite (per-daemon) API: HTTP REST API + CLI Features: Basic lifecycle, 24h minimum tracking, SSH-based setup
- Implement Nimbus.Daemon.Config (load from TOML/env)
- Implement Nimbus.Daemon.Supervisor (per-daemon supervision tree)
- Set up Nimbus.Application with OTP supervision
- Implement Nimbus.Storage behavior
- Implement Nimbus.Storage.SQLite (default storage)
- Define Provider behavior
- Implement Nimbus.Machine struct and core functions
- Set up telemetry with :telemetry library
- Implement Local provider for development/testing
- Add MuonTrap for process management
- Set up Plug/Bandit web server
- Implement Nimbus.API.Router (endpoint routing)
- Implement Nimbus.API.Health (health checks)
- Implement Nimbus.API.Machines (CRUD endpoints)
- Implement Nimbus.API.Providers (read-only endpoints)
- Implement Nimbus.API.Config (daemon config endpoint)
- Add authentication/authorization (API keys)
- Set up CLI framework (mix escript or Burrito)
- Implement
nimbus init(interactive config setup) - Implement
nimbus start/stop/restart/status - Implement
nimbus providercommands - Implement
nimbus machinecommands - Implement
nimbus logs(tail daemon logs) - Add shell completions (bash/zsh/fish)
- Implement Nimbus.Provider.AWS
- Handle EC2 dedicated host allocation
- Handle mac2.metal instance provisioning
- Tag resources with daemon_id and nimbus metadata
- Implement machine discovery via AWS API
- Implement can_terminate? with 24h check
- Handle host + instance cleanup
- Implement GitHub App authentication
- Implement runner registration token API
- Implement runner registration API
- Implement runner unregistration API
- Handle API errors and retries
- Implement Nimbus.XDG for XDG Base Directory paths
- Implement Nimbus.Machine.Setup module
- Implement GitHub Actions runner installer
- Implement Curie installer (macOS only)
- Implement Geranos installer (macOS only)
- Integrate setup into Local provider
- Implement Nimbus.Machine.SSH module (for remote execution)
- Configure and register runner with forge
- Health check and verification
- Initialize machine state file (SQLite on machine)
- Unit tests for XDG module
- Unit tests for Machine.Setup module (with mocked installers)
- Unit tests for remaining core modules
- Mocked AWS API tests
- Mocked GitHub API tests
- Integration tests (may require real AWS/GitHub sandbox)
- CLI integration tests
- API endpoint tests
- Module documentation (@moduledoc)
- Function documentation (@doc)
- Self-hosted deployment guide
- Configuration file documentation
- CLI command reference
- API endpoint reference
- Provider setup guides (AWS, GitHub)
# mix.exs dependencies
# HTTP Server & API
{:plug, "~> 1.15"},
{:bandit, "~> 1.0"}, # HTTP server
{:jason, "~> 1.4"}, # JSON encoding/decoding
{:cors_plug, "~> 3.0"}, # CORS support
# Storage
{:ecto_sql, "~> 3.11"},
{:ecto_sqlite3, "~> 0.14"}, # SQLite adapter
{:postgrex, "~> 0.17"}, # Optional: Postgres adapter
# Cloud Providers
{:ex_aws, "~> 2.5"},
{:ex_aws_ec2, "~> 2.0"},
{:hackney, "~> 1.18"},
# Git Forges
{:req, "~> 0.4"}, # HTTP client for GitHub/GitLab APIs
# SSH & Process Management
{:sshex, "~> 2.2"}, # For SSH operations
{:muontrap, "~> 1.5"}, # For process management
# Configuration & Validation
{:nimble_options, "~> 1.1"}, # Config validation
{:toml, "~> 0.7"}, # TOML config parsing
# Telemetry & Monitoring
{:telemetry, "~> 1.2"},
{:telemetry_metrics, "~> 0.6"},
{:telemetry_poller, "~> 1.0"},
# CLI (optional, for escript build)
{:owl, "~> 0.9"}, # CLI framework with interactive promptsNew Components:
- Control plane orchestrator (Elixir/Phoenix app)
- Daemon hibernation & restore logic
- Multi-tenant daemon management
- UI dashboard (Phoenix LiveView)
- Billing/metering integration
- Implement Nimbus.Daemon.State.save() (persist to S3/storage)
- Implement Nimbus.Daemon.State.restore() (load from storage)
- POST /hibernate endpoint (graceful shutdown + save)
- POST /restore endpoint (load state + restart)
- Background hibernation after N minutes idle
- Phoenix app for control plane
- Daemon registry (track daemon IDs, status, endpoints)
- Daemon lifecycle management (spawn, monitor, hibernate)
- Proxy API requests to tenant daemons
- LiveView dashboard (tenant overview, machines, costs)
- EC2 instance management (bin packing daemons)
- Daemon spawning via systemd/K8s
- Health monitoring & auto-restart
- Load balancing across orchestrator instances
- Pre-provision N machines per tenant/provider
- Maintain minimum pool size
- Automatic replenishment
- Pool sizing strategies
- Hetzner dedicated servers
- AWS EC2 Linux instances
- GCP (future)
- Azure (future)
- GitLab (via personal access token or OAuth)
- Forgejo (similar to GitLab)
- Auto-terminate idle machines (after minimum period)
- Auto-provision based on queue depth
- Cost optimization strategies
- Usage analytics and reporting
- Machine health monitoring
- Automatic recovery from failures
- Runner agent updates
- Multi-region support
- Cost tracking and budgets
- Set up mise.toml with lockfile enabled
- Created Elixir application structure using mix
- Created CLAUDE.md and README.md documentation
- Designed architecture and data models
- Set up Quokka (code formatter/linter) and Mimic (mocking library)
- Configured .formatter.exs with Quokka plugin
- Generated .credo.exs configuration
- Updated CLAUDE.md with development workflow and pre-commit checklist
- Define core data structures (Tenant, Provider.Config, Forge.Config, Machine)
- Define Nimbus.Storage behavior with delegation functions
- Define Nimbus.Provider behavior with delegation functions
- Implement Nimbus.Provider.Local for development/testing
- Set up Nimbus.Telemetry with event helpers and convenience macro
- Add telemetry dependency (~> 1.2)
- Add MuonTrap for process management (~> 1.5)
- Implement public API (Nimbus module) with provision/terminate/list/get functions
- SSH Key Management: Should we support multiple SSH keys per tenant for different purposes?
- GitHub Runner Scope: Organization-level vs repository-level runners?
- Error Handling: Retry strategies for transient failures (AWS API throttling, SSH timeouts)?
- Logging: Use Logger or rely purely on telemetry?
- Machine Naming: Convention for naming machines/runners (e.g., "nimbus-{tenant}-{uuid}")?
- macOS VM Concurrency Limits:
- Legal/Licensing: Apple's EULA restricts macOS virtualization - only allowed on Apple hardware, and with specific conditions:
- macOS can be virtualized on Apple Silicon using macOS 12.0.1+ (Virtualization.framework)
- Up to 2 VM instances per physical Mac
- Each VM requires a separate license
- AWS EC2 Mac Limitations: AWS mac2.metal is bare metal (not virtualized), so Apple's 2-VM limit doesn't apply. However:
- 24-hour minimum allocation per dedicated host
- One instance per host (bare metal)
- No concurrent VMs on same host - each tenant gets full dedicated host
- Question: Do we need to track/enforce any concurrency limits per tenant? Or rely on AWS account limits?
- Legal/Licensing: Apple's EULA restricts macOS virtualization - only allowed on Apple hardware, and with specific conditions:
- macOS Image Management:
- Problem: Unlike Linux (Docker images), macOS images are installed separately:
- AWS provides AMIs (Amazon Machine Images) with pre-installed macOS versions
- Additional software (Xcode, simulators) must be installed after provisioning via SSH
- Images are large (50GB+) and installation is slow (30+ minutes for Xcode)
- Modeling Approach (DECIDED):
- Option A (MVP): Add
image_idto specs, track image lifecycle in Machine structspecs = %{ os: :macos, arch: :arm64, image_id: "ami-123abc", # macOS 14.2 base image_type: :ami, setup_script: "install_xcode_15.sh" # Run via SSH after provision } # Machine struct tracks image state %Machine{ state: :image_installing, # or :ready when complete image: %{ id: "ami-123abc", type: :ami, state: :provisioning, # transitions to :ready installed_at: nil } }
- Option A (MVP): Add
- Design Decision:
- Machine struct now includes
imagefield to track software configuration - New machine state:
:image_installing(between:provisioningand:ready) - Linux: Can transition directly to
:ready(pre-built images) - macOS: Goes through image installation phase (Xcode, etc.)
- Machine struct now includes
- Future: Can split into separate Host/VM concepts when supporting 2 VMs per physical Mac
- Question: Should we cache/reuse provisioned machines with software pre-installed (warm pool), or always provision fresh?
- Problem: Unlike Linux (Docker images), macOS images are installed separately:
Previous Architecture: Nimbus was designed as an Elixir library to be embedded into host applications (like Tuist server), with storage abstraction expecting the integrator to provide implementation.
New Architecture: Nimbus is now a standalone per-tenant daemon with:
- Standalone value: Useful independently for elastic CI runners
- Grafana Cloud model: Can be self-hosted or managed by Tuist
- Per-daemon isolation: Each tenant gets isolated BEAM process
- Built-in storage: SQLite by default, Postgres optional
- HTTP API + CLI: RESTful API and command-line interface
- Hibernation support: Cost-efficient for hosted deployments
Rationale:
- Library-only approach has no standalone value
- Hard to market/grow adoption without independent utility
- Per-tenant daemon enables hosted SaaS model (like Grafana Cloud)
- Creates natural pathway: OSS → Managed control plane → Full Tuist platform
- Better economics: ~$0.10/tenant/month with hibernation
Migration Impact:
- Old
Nimbus.Storagebehavior removed (storage now built-in) - Old
Nimbus.provision_machine(tenant_id, ...)becomes daemon API:POST /machines - Configuration moves from host app to per-daemon config files
- Tenant concept becomes implicit (one daemon = one tenant)
- AWS Mac dedicated hosts have 24-hour minimum allocation (billing constraint)
- Bare metal instances (mac2.metal, mac2-m2.metal, mac2-m2pro.metal)
- One instance per dedicated host (no concurrent VMs)
- Uses Nitro System but not virtualized - direct hardware access
- Must allocate dedicated host first, then launch instance on that host
- Apple's EULA: macOS virtualization only on Apple hardware
- Maximum 2 concurrent VMs per physical Mac (using Virtualization.framework)
- AWS EC2 Mac is compliant (bare metal on Apple hardware)
- Each macOS instance requires separate license
- macOS images distributed as AMIs (Amazon Machine Images)
- Base OS only - additional software installed post-provision
- Large images (50GB+) with slow installation times (30+ min for Xcode)
- Xcode includes: IDE, SDKs, simulators, command-line tools (~40GB installed)
- Machine discovery uses cloud provider tags instead of storing state
- Integrator provides storage implementation and SSH keys
- Nimbus manages complete lifecycle including forge integration
Design Decision: State Lives on the Machine
We persist detailed machine state on the machine itself (via SSH-accessible file/database), rather than in the integrating application's storage. This aligns with our "lean state" principle.
Two-Layer State Model:
-
Basic State (from Cloud Provider API)
- Machine existence, IP address, running/stopped status
- Discovered via
Provider.list_machines()andProvider.get_machine() - Available immediately after provisioning
- Source of truth: Cloud provider tags/API
-
Detailed State (from Machine Filesystem)
- Image installation progress
- Runner registration status
- Setup step completion
- Application-specific metadata
- Available once SSH-accessible
- Source of truth: File/database on machine
State Persistence Options:
Option A: JSON File on Disk
/var/lib/nimbus/state.json- Pros: Simple, human-readable, no dependencies, easy to write
- Cons: Poor queryability (must read entire file), harder to debug at scale
- Example:
cat state.jsondumps everything, can't filter or query specific fields
Option B: SQLite Database (CHOSEN)
/var/lib/nimbus/state.db- Pros: Queryable via SSH, atomic writes, transactional, battle-tested
- Cons: Binary format (but sqlite3 CLI is ubiquitous), need schema
- Queryability is key for debugging provisioning issues
- Example queries:
# Check failed steps sqlite3 state.db 'SELECT * FROM setup_steps WHERE status = "failed"' # See timing information sqlite3 state.db 'SELECT step, started_at, completed_at FROM setup_steps' # Check current state sqlite3 state.db 'SELECT state, updated_at FROM machine_state'
Option C: etcd/Consul (Embedded)
- Pros: Distributed, consistent, good for multi-VM scenarios
- Cons: Heavy dependency, overkill for single machine
- Only relevant for future multi-VM per physical machine scenarios
Decision: SQLite (Option B)
- Queryability: Can SSH in and query specific state, filter results, check timing
- Debugging: When setup fails, can quickly identify which step and when
- Atomic writes: Built-in transaction support prevents corruption
- Future-proof: Can add logs, metrics, multiple tables as needed
- Ubiquitous: sqlite3 CLI is available on all Unix systems
- The slight complexity of SQL schema is worth the debugging benefits
SQLite Schema (Draft):
-- Machine state table (singleton - one row)
CREATE TABLE machine_state (
machine_id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
state TEXT NOT NULL, -- provisioning, image_installing, ready, running, stopping, terminated
created_at TEXT NOT NULL, -- ISO8601 timestamp
updated_at TEXT NOT NULL
);
-- Image installation state (singleton - one row if image is used)
CREATE TABLE image_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Enforce single row
image_id TEXT NOT NULL,
image_type TEXT NOT NULL, -- ami, docker
state TEXT NOT NULL, -- provisioning, ready
started_at TEXT,
installed_at TEXT
);
-- Setup steps tracking (multiple rows)
CREATE TABLE setup_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
step TEXT NOT NULL, -- install_homebrew, install_xcode, register_runner
status TEXT NOT NULL, -- pending, in_progress, completed, failed
started_at TEXT,
completed_at TEXT,
error_message TEXT,
seq INTEGER NOT NULL -- Order of execution
);
CREATE INDEX idx_setup_steps_status ON setup_steps(status);
CREATE INDEX idx_setup_steps_seq ON setup_steps(seq);
-- Runner registration state (singleton - one row)
CREATE TABLE runner_state (
id INTEGER PRIMARY KEY CHECK (id = 1), -- Enforce single row
registered BOOLEAN NOT NULL DEFAULT 0,
runner_id TEXT,
registration_token TEXT, -- TODO: Encrypt?
labels TEXT, -- JSON array: ["macos", "xcode-15"]
registered_at TEXT
);Example Elixir representation after reading from SQLite:
# Read from SQLite via SSH, construct Machine struct
%Machine{
id: "machine-uuid",
tenant_id: "tenant-123",
provider_id: "provider-456",
state: :image_installing,
# ... other fields from provider API ...
# Enriched from SQLite:
image: %{
id: "ami-123abc",
type: :ami,
state: :provisioning,
installed_at: nil
},
setup_progress: [
%{step: "install_homebrew", status: :completed, started_at: "...", completed_at: "..."},
%{step: "install_xcode", status: :in_progress, started_at: "...", completed_at: nil},
%{step: "register_runner", status: :pending, started_at: nil, completed_at: nil}
]
}Implementation Flow:
1. Provider.provision() creates machine in cloud
→ Returns Machine struct with basic info from provider API
→ State: :provisioning, SSH not accessible yet
2. Wait for SSH to become accessible (poll/retry)
→ Once accessible, initialize state persistence
→ Create /var/lib/nimbus/state.db (SQLite) with schema
→ Insert initial state: machine_id, tenant_id, provider_id, state: :provisioning
3. Begin setup process
→ Update state to :image_installing
→ Record setup steps as they progress
→ State file tracks: current step, progress, timestamps
4. Setup completes
→ Update state to :ready
→ Record completion timestamp
5. get_machine() / list_machines() enriches data
→ Query provider API for basic info (running, IP, etc.)
→ If SSH-accessible: query state.db and merge with provider data
→ If SSH-unavailable: return provider data only (fallback)
→ Example query: `ssh machine "sqlite3 /var/lib/nimbus/state.db 'SELECT * FROM machine_state'"`
Open Questions:
- State initialization timing: Should we initialize state file immediately after SSH becomes available, or wait until setup begins?
- State encryption: Should sensitive data (runner tokens, etc.) be encrypted in the state file?
- State backup: Should we periodically sync state to integrator's storage for disaster recovery?
State format: JSON vs SQLite vs other?DECIDED: JSON with atomic writes (see above)- State location:
/var/lib/nimbus/vs/opt/nimbus/vs home directory? - Handling missing state: If state file is missing/corrupted, do we recreate from provider API + reinitialize?
- Local provider: How do we handle state for local machines without actual SSH? (Mock file in temp directory?)
Implementation Notes (For Later):
# SQLite operations via SSH
defmodule Nimbus.Machine.State do
@state_db "/var/lib/nimbus/state.db"
# Initialize database with schema
def init_via_ssh(machine) do
commands = [
"mkdir -p /var/lib/nimbus",
"sqlite3 #{@state_db} '#{create_schema_sql()}'"
]
Enum.each(commands, fn cmd ->
Nimbus.Provider.Local.exec_command(machine, cmd)
end)
end
# Update machine state
def update_state_via_ssh(machine, state) do
sql = """
UPDATE machine_state
SET state = '#{state}', updated_at = datetime('now')
WHERE machine_id = '#{machine.id}'
"""
exec_sql_via_ssh(machine, sql)
end
# Read machine state
def read_state_via_ssh(machine) do
sql = "SELECT * FROM machine_state"
case exec_sql_via_ssh(machine, sql) do
{:ok, output} -> parse_sqlite_output(output)
error -> error
end
end
defp exec_sql_via_ssh(machine, sql) do
cmd = "sqlite3 #{@state_db} \"#{escape_sql(sql)}\""
Nimbus.Provider.Local.exec_command(machine, cmd)
end
endAll database operations will be executed via SSH commands, not direct database connections.