Social cloud hosting
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: at-rund alpha

AT Protocol serverless runtime daemon - social cloud hosting where runners
represent their operators on the network and trust flows through the social graph.

Features:
- ATProto integration (DID resolution, PDS client, bundle fetching/caching)
- Dev mode execution via Nix runtimes
- Deno runtime with permission translation
- HTTP API for bundle execution (/bundle/, /at://)
- Access control (allowlist/blocklist/open modes)
- Observability (structured logging, metrics endpoint, trace IDs)
- systemd service support
- Lima config for macOS development

Co-Authored-By: Claude <noreply@anthropic.com>

neutrino2211 14a93d73

+4243
+15
.gitignore
··· 1 + # Binaries 2 + /at-rund 3 + *.exe 4 + 5 + # IDE 6 + .idea/ 7 + .vscode/ 8 + *.swp 9 + 10 + # OS 11 + .DS_Store 12 + 13 + # Build artifacts 14 + dist/ 15 + result
+265
README.md
··· 1 + # at-rund 2 + 3 + **Social cloud hosting for AT Protocol.** 4 + 5 + at-rund lets you host serverless bundles for the AT Protocol network. Your runner represents *you* — bundle authors trust your infrastructure because they trust you. 6 + 7 + ## The Idea 8 + 9 + Today, running code on the internet means trusting faceless cloud providers. at-rund flips this: anyone can host a runner, and trust flows through the social graph. 10 + 11 + ``` 12 + ┌─────────────────────────────────────────────────────────────────────────────┐ 13 + │ Social Cloud Hosting │ 14 + │ │ 15 + │ "I run an at-rund instance. Trust me because you know me." │ 16 + │ │ 17 + ├─────────────────────────────────────────────────────────────────────────────┤ 18 + │ │ 19 + │ @alice.bsky.social @bob.example.com │ 20 + │ runs at-run.alice.dev runs compute.bob.example.com │ 21 + │ ├─ deno + ffmpeg ├─ deno │ 22 + │ ├─ python + pytorch ├─ node │ 23 + │ └─ allowlist: friends └─ open to all │ 24 + │ │ 25 + │ Bundle authors choose runners based on: │ 26 + │ • Social trust (I know Alice) │ 27 + │ • Capabilities (Alice has ffmpeg) │ 28 + │ • Availability (Bob's is always up) │ 29 + │ │ 30 + └─────────────────────────────────────────────────────────────────────────────┘ 31 + ``` 32 + 33 + ## How It Works 34 + 35 + 1. **Bundle authors** write serverless functions and store them on their AT Protocol PDS 36 + 2. **Runners** (like you) host at-rund instances that execute those bundles 37 + 3. **Trust** is social — authors choose runners they trust, runners choose who can use them 38 + 39 + ``` 40 + Bundle Author Runner Host 41 + │ │ 42 + │ 1. Write bundle │ 43 + │ 2. Deploy to PDS │ 44 + │ 3. Encrypt secrets for runner │ 45 + │ │ 46 + └──────────── request ───────────────▶│ 47 + │ 4. Fetch bundle from PDS 48 + │ 5. Execute in sandbox 49 + │ 6. Return result 50 + ◀─────────── response ────────────────┘ 51 + ``` 52 + 53 + ## Quick Start 54 + 55 + ### Install 56 + 57 + ```bash 58 + # Download at-rund 59 + curl -sSL https://at-run.dev/install.sh | sh 60 + 61 + # Install Nix (required for runtimes) 62 + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh 63 + ``` 64 + 65 + ### Initialize 66 + 67 + ```bash 68 + at-rund init 69 + ``` 70 + 71 + This creates `~/.at-rund/` with default configuration and runtime definitions. 72 + 73 + ### Configure 74 + 75 + Edit `~/.at-rund/config.toml`: 76 + 77 + ```toml 78 + # Your identity — the runner IS you 79 + did = "did:plc:your-did-here" 80 + handle = "you.bsky.social" 81 + 82 + port = 3000 83 + 84 + [access] 85 + # Who can use your runner? 86 + # "open" - anyone 87 + # "allowlist" - only specific DIDs 88 + # "blocklist" - everyone except specific DIDs 89 + mode = "open" 90 + 91 + [runtimes] 92 + # Which runtimes you support 93 + deno = "deno.nix" 94 + ``` 95 + 96 + ### Run 97 + 98 + ```bash 99 + # Development (uses Nix directly, no VM isolation) 100 + at-rund serve --dev 101 + 102 + # Production (uses Firecracker VMs, requires Linux + KVM) 103 + at-rund build # Build VM images 104 + at-rund serve # Start server 105 + ``` 106 + 107 + ### Deploy as a Service 108 + 109 + ```bash 110 + # Install systemd service 111 + at-rund systemd install 112 + 113 + # Start and enable 114 + sudo systemctl enable --now at-rund 115 + 116 + # Check status 117 + at-rund systemd status 118 + ``` 119 + 120 + ## Architecture 121 + 122 + at-rund has two execution modes: 123 + 124 + ### Dev Mode (macOS, Linux without KVM) 125 + 126 + Uses Nix to run bundles directly. Fast iteration, same runtimes as production, but no isolation. 127 + 128 + ``` 129 + Request → Nix shell → deno run bundle.js → Response 130 + ``` 131 + 132 + ### Prod Mode (Linux + KVM) 133 + 134 + Uses Firecracker microVMs for full isolation. Each bundle runs in its own VM with only the permissions it declared. 135 + 136 + ``` 137 + Request → Firecracker VM → deno run bundle.js → Response 138 + 139 + └─ virtio-fs mount (bundle code) 140 + └─ vsock (host ↔ guest RPC) 141 + ``` 142 + 143 + Both modes use the same Nix-defined runtimes, so bundles behave identically. 144 + 145 + ## Custom Runtimes 146 + 147 + Runtimes are defined with Nix. You can customize the defaults or create your own: 148 + 149 + ```nix 150 + # ~/.at-rund/runtimes/python-ml.nix 151 + { pkgs, ... }: 152 + { 153 + mimeTypes = [ 154 + "application/python+ml" 155 + ]; 156 + 157 + guest = { 158 + environment.systemPackages = with pkgs; [ 159 + python312 160 + python312Packages.pytorch 161 + python312Packages.numpy 162 + python312Packages.pillow 163 + ]; 164 + }; 165 + 166 + executor = { 167 + command = "python3"; 168 + permissionFlags = {}; 169 + }; 170 + } 171 + ``` 172 + 173 + Then enable it in your config: 174 + 175 + ```toml 176 + [runtimes] 177 + deno = "deno.nix" 178 + python-ml = "python-ml.nix" 179 + ``` 180 + 181 + ## Access Control 182 + 183 + Control who can run bundles on your infrastructure: 184 + 185 + ```toml 186 + [access] 187 + # Open to everyone 188 + mode = "open" 189 + 190 + # Only allow specific people 191 + mode = "allowlist" 192 + allowlist = [ 193 + "did:plc:friend1", 194 + "did:plc:friend2", 195 + ] 196 + 197 + # Block bad actors 198 + mode = "blocklist" 199 + blocklist = [ 200 + "did:plc:spammer", 201 + ] 202 + ``` 203 + 204 + For more complex policies (payments, quotas, rate limiting), put a reverse proxy in front of at-rund. 205 + 206 + ## Observability 207 + 208 + at-rund supports OpenTelemetry for metrics and traces: 209 + 210 + ```toml 211 + [observability] 212 + otlp_endpoint = "http://localhost:4317" 213 + log_format = "json" 214 + ``` 215 + 216 + This lets you connect to Grafana, Jaeger, or any OTLP-compatible backend. 217 + 218 + ## CLI Reference 219 + 220 + ``` 221 + at-rund 222 + ├── init Initialize ~/.at-rund/ 223 + ├── serve Run the server 224 + │ ├── --dev Dev mode (Nix direct execution) 225 + │ ├── --port PORT Override port 226 + │ └── --config PATH Custom config path 227 + ├── build Build Firecracker VM images 228 + │ └── --runtime NAME Build specific runtime only 229 + ├── runtime 230 + │ └── list Show configured runtimes 231 + ├── pool 232 + │ ├── status VM pool statistics 233 + │ ├── warm Pre-warm VMs 234 + │ └── drain Graceful shutdown 235 + └── systemd 236 + ├── install Install systemd service 237 + │ └── --user User service (no sudo) 238 + ├── uninstall Remove service 239 + └── status Show service status 240 + ``` 241 + 242 + ## Project Structure 243 + 244 + ``` 245 + ~/.at-rund/ 246 + ├── config.toml # Main configuration 247 + ├── runtimes/ # Nix runtime definitions 248 + │ ├── deno.nix 249 + │ ├── node.nix 250 + │ └── python.nix 251 + ├── images/ # Built Firecracker images (prod) 252 + ├── bundles/ # Cached bundle code 253 + └── keys/ # Runner keypair (for secrets) 254 + ``` 255 + 256 + ## Related Projects 257 + 258 + - **[at-run](https://github.com/neutrino2211/at-run)** — Developer CLI for deploying bundles 259 + - **[AT Protocol](https://atproto.com)** — Decentralized social protocol 260 + - **[Firecracker](https://firecracker-microvm.github.io/)** — Lightweight virtualization 261 + - **[Nix](https://nixos.org)** — Reproducible builds 262 + 263 + ## License 264 + 265 + MIT
+185
ROADMAP.md
··· 1 + # Roadmap 2 + 3 + This document outlines the development roadmap for at-rund. 4 + 5 + ## Current Status: Alpha 6 + 7 + The core architecture is in place. Dev mode works on macOS/Linux with Nix. Production mode (Firecracker) is scaffolded but not yet functional. 8 + 9 + --- 10 + 11 + ## Phase 1: Core Functionality 12 + 13 + **Goal:** A working end-to-end system where bundles can be fetched from a PDS and executed. 14 + 15 + ### ATProto Integration 16 + - [ ] DID resolution (did:plc, did:web) 17 + - [ ] PDS client for fetching bundle records 18 + - [ ] Bundle blob fetching and caching 19 + - [ ] Manifest parsing (permissions, runtime, limits) 20 + 21 + ### Bundle Execution 22 + - [ ] Wire up executor to HTTP routes 23 + - [ ] Permission enforcement (net, read, write, env) 24 + - [ ] Resource limits (memory, CPU, timeout) 25 + - [ ] Secrets decryption and injection 26 + 27 + ### Dev Mode Polish 28 + - [x] Nix-based execution 29 + - [x] Auto-detection of KVM availability 30 + - [ ] Hot reload for local development 31 + - [ ] Better error messages 32 + 33 + --- 34 + 35 + ## Phase 2: Production Mode 36 + 37 + **Goal:** Secure, isolated execution using Firecracker microVMs. 38 + 39 + ### Firecracker Integration 40 + - [ ] VM lifecycle management (spawn, stop, reuse) 41 + - [ ] Kernel + rootfs image building via Nix 42 + - [ ] virtio-fs for bundle mounting 43 + - [ ] vsock for host ↔ guest communication 44 + 45 + ### Guest Agent 46 + - [ ] Go binary running inside VMs 47 + - [ ] Execute bundles with permission flags 48 + - [ ] Report metrics (memory, CPU, execution time) 49 + - [ ] Health checks 50 + 51 + ### VM Pool 52 + - [ ] Pre-warming (configurable per runtime) 53 + - [ ] Idle timeout and reclamation 54 + - [ ] Max VM limits 55 + - [ ] Graceful drain on shutdown 56 + 57 + ### Network Proxy 58 + - [ ] Bundle network requests proxied through host 59 + - [ ] Permission enforcement (allowed hosts) 60 + - [ ] Request logging 61 + 62 + --- 63 + 64 + ## Phase 3: Observability 65 + 66 + **Goal:** Operators can monitor their runners effectively. 67 + 68 + ### Metrics 69 + - [ ] OpenTelemetry integration 70 + - [ ] Request count, latency, error rate 71 + - [ ] Per-bundle, per-DID breakdowns 72 + - [ ] VM pool utilization 73 + - [ ] Resource usage (memory, CPU) 74 + 75 + ### Logging 76 + - [ ] Structured JSON logs 77 + - [ ] Request tracing (trace IDs) 78 + - [ ] Bundle execution logs (opt-in) 79 + 80 + ### Dashboard 81 + - [ ] Example Grafana dashboard 82 + - [ ] Prometheus scrape endpoint (`/metrics`) 83 + 84 + --- 85 + 86 + ## Phase 4: Operator Experience 87 + 88 + **Goal:** Make it easy to run a production at-rund instance. 89 + 90 + ### Deployment 91 + - [x] systemd service support 92 + - [ ] Docker image 93 + - [ ] Nix flake for NixOS deployment 94 + - [ ] Ansible/Terraform examples 95 + 96 + ### Configuration 97 + - [ ] Config validation on startup 98 + - [ ] Reload config without restart (SIGHUP) 99 + - [ ] Environment variable overrides 100 + 101 + ### Security 102 + - [ ] Security hardening guide 103 + - [ ] Firewall recommendations 104 + - [ ] TLS termination examples (nginx, caddy) 105 + 106 + --- 107 + 108 + ## Phase 5: Advanced Features 109 + 110 + **Goal:** Features for larger-scale or specialized deployments. 111 + 112 + ### Tasks & Jobs 113 + - [ ] Port task queue from at-run v1 114 + - [ ] Background job execution 115 + - [ ] Cron scheduling 116 + - [ ] Result caching 117 + 118 + ### Multi-Node 119 + - [ ] Shared state (Redis, SQLite) 120 + - [ ] Load balancing considerations 121 + - [ ] Sticky sessions for stateful bundles 122 + 123 + ### Custom Runtimes 124 + - [ ] Runtime marketplace/registry (community-contributed) 125 + - [ ] Documentation for writing runtimes 126 + - [ ] Testing framework for runtimes 127 + 128 + --- 129 + 130 + ## Phase 6: Ecosystem 131 + 132 + **Goal:** at-rund becomes part of a thriving ecosystem. 133 + 134 + ### Discovery 135 + - [ ] Runner announcement protocol (optional) 136 + - [ ] Capability advertisement (runtimes, limits) 137 + - [ ] Uptime/health signaling 138 + 139 + ### Developer Experience 140 + - [ ] `at-run test --runner <url>` for testing against remote runners 141 + - [ ] Bundle compatibility checker 142 + - [ ] Performance profiling 143 + 144 + ### Documentation 145 + - [ ] Operator guide 146 + - [ ] Security model explanation 147 + - [ ] Troubleshooting guide 148 + - [ ] Video tutorials 149 + 150 + --- 151 + 152 + ## Non-Goals (For Now) 153 + 154 + These are explicitly out of scope for the initial releases: 155 + 156 + - **Automatic runner discovery** — Trust is social; discovery is manual 157 + - **Payment/billing integration** — Use middleware if needed 158 + - **Multi-region orchestration** — Each runner is independent 159 + - **Bundle validation/signing** — Trust the author, not the code 160 + - **Centralized registry** — Bundles live on user PDSes 161 + 162 + --- 163 + 164 + ## Contributing 165 + 166 + We welcome contributions! Areas where help is especially appreciated: 167 + 168 + 1. **Runtime definitions** — Create Nix configs for new runtimes 169 + 2. **Testing** — Run at-rund and report issues 170 + 3. **Documentation** — Improve guides and examples 171 + 4. **Firecracker expertise** — Help with VM integration 172 + 173 + See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. 174 + 175 + --- 176 + 177 + ## Version History 178 + 179 + | Version | Status | Notes | 180 + |---------|--------|-------| 181 + | 0.1.0 | Alpha | Initial scaffolding, dev mode works | 182 + | 0.2.0 | — | ATProto integration, bundle execution | 183 + | 0.3.0 | — | Firecracker production mode | 184 + | 0.4.0 | — | Observability (OTLP, metrics) | 185 + | 1.0.0 | — | Production ready |
+13
cmd/at-rund/main.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + 6 + "github.com/neutrino2211/at-rund/internal/cli" 7 + ) 8 + 9 + func main() { 10 + if err := cli.Execute(); err != nil { 11 + os.Exit(1) 12 + } 13 + }
+14
go.mod
··· 1 + module github.com/neutrino2211/at-rund 2 + 3 + go 1.23.8 4 + 5 + require ( 6 + github.com/pelletier/go-toml/v2 v2.3.0 7 + github.com/spf13/cobra v1.10.2 8 + ) 9 + 10 + require ( 11 + github.com/google/uuid v1.6.0 // indirect 12 + github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 + github.com/spf13/pflag v1.0.9 // indirect 14 + )
+14
go.sum
··· 1 + github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 3 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 4 + github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 5 + github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 6 + github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= 7 + github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 8 + github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 9 + github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 10 + github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 11 + github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 12 + github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 13 + go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 14 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+393
internal/atproto/bundle.go
··· 1 + package atproto 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "regexp" 10 + "sort" 11 + "strconv" 12 + "strings" 13 + "sync" 14 + ) 15 + 16 + const ( 17 + BundleCollection = "dev.mainasara.at-run.beta.bundle" 18 + ) 19 + 20 + // BundleRecord represents a bundle record from the PDS 21 + type BundleRecord struct { 22 + Name string `json:"name"` 23 + Description string `json:"description,omitempty"` 24 + Version string `json:"version"` 25 + Runtime string `json:"runtime"` 26 + Blob *BlobRef `json:"blob"` 27 + Permissions Permissions `json:"permissions"` 28 + CreatedAt string `json:"createdAt"` 29 + } 30 + 31 + // BlobRef is a reference to a blob in ATProto 32 + type BlobRef struct { 33 + Type string `json:"$type"` 34 + Ref CIDRef `json:"ref"` 35 + MimeType string `json:"mimeType"` 36 + Size int64 `json:"size"` 37 + } 38 + 39 + // CIDRef is a CID reference 40 + type CIDRef struct { 41 + Link string `json:"$link"` 42 + } 43 + 44 + // Permissions declared by a bundle 45 + type Permissions struct { 46 + Net []string `json:"net,omitempty"` 47 + Read []string `json:"read,omitempty"` 48 + Write []string `json:"write,omitempty"` 49 + Env []string `json:"env,omitempty"` 50 + Run []string `json:"run,omitempty"` 51 + FFI []string `json:"ffi,omitempty"` 52 + Sys []string `json:"sys,omitempty"` 53 + HRTime bool `json:"hrtime,omitempty"` 54 + } 55 + 56 + // Bundle represents a fetched and cached bundle 57 + type Bundle struct { 58 + URI string 59 + DID string 60 + Name string 61 + Version string 62 + Runtime string 63 + Permissions Permissions 64 + CodePath string // Local path to the bundle code 65 + } 66 + 67 + // BundleRef is a reference to a bundle (by AT URI or name) 68 + type BundleRef struct { 69 + Type string // "at" or "named" 70 + URI string // For "at" type 71 + DID string // For "named" type 72 + Name string // For "named" type 73 + Version string // For "named" type (can be "latest") 74 + } 75 + 76 + // BundleClient fetches and caches bundles from the AT Protocol network 77 + type BundleClient struct { 78 + didResolver *DIDResolver 79 + cacheDir string 80 + devMode bool 81 + 82 + // Caches 83 + mu sync.RWMutex 84 + bundleCache map[string]*Bundle // AT URI -> Bundle 85 + versionCache map[string]string // did/name/version -> AT URI 86 + pdsClientCache map[string]*PDSClient // PDS URL -> client 87 + } 88 + 89 + func NewBundleClient(cacheDir string, devMode bool) *BundleClient { 90 + return &BundleClient{ 91 + didResolver: NewDIDResolver(), 92 + cacheDir: cacheDir, 93 + devMode: devMode, 94 + bundleCache: make(map[string]*Bundle), 95 + versionCache: make(map[string]string), 96 + pdsClientCache: make(map[string]*PDSClient), 97 + } 98 + } 99 + 100 + // GetBundle fetches a bundle by reference 101 + func (c *BundleClient) GetBundle(ref BundleRef) (*Bundle, error) { 102 + var atURI string 103 + var err error 104 + 105 + switch ref.Type { 106 + case "at": 107 + atURI = ref.URI 108 + case "named": 109 + atURI, err = c.resolveNamedBundle(ref.DID, ref.Name, ref.Version) 110 + if err != nil { 111 + return nil, fmt.Errorf("failed to resolve bundle: %w", err) 112 + } 113 + default: 114 + return nil, fmt.Errorf("invalid bundle ref type: %s", ref.Type) 115 + } 116 + 117 + return c.fetchBundle(atURI) 118 + } 119 + 120 + // resolveNamedBundle resolves a named bundle to its AT URI 121 + func (c *BundleClient) resolveNamedBundle(did, name, version string) (string, error) { 122 + cacheKey := fmt.Sprintf("%s/%s/%s", did, name, version) 123 + 124 + // Don't cache "latest" in dev mode 125 + if version != "latest" || !c.devMode { 126 + c.mu.RLock() 127 + if uri, ok := c.versionCache[cacheKey]; ok { 128 + c.mu.RUnlock() 129 + return uri, nil 130 + } 131 + c.mu.RUnlock() 132 + } 133 + 134 + // Get PDS client for this DID 135 + pds, err := c.getPDSClient(did) 136 + if err != nil { 137 + return "", err 138 + } 139 + 140 + // List all bundles for this DID 141 + resp, err := pds.ListRecords(did, BundleCollection, 100, "") 142 + if err != nil { 143 + return "", err 144 + } 145 + 146 + // Find matching bundles 147 + type bundleVersion struct { 148 + uri string 149 + version string 150 + } 151 + var matches []bundleVersion 152 + 153 + for _, record := range resp.Records { 154 + var bundle BundleRecord 155 + if err := json.Unmarshal(record.Value, &bundle); err != nil { 156 + continue 157 + } 158 + if bundle.Name == name { 159 + matches = append(matches, bundleVersion{ 160 + uri: record.URI, 161 + version: bundle.Version, 162 + }) 163 + } 164 + } 165 + 166 + if len(matches) == 0 { 167 + return "", fmt.Errorf("bundle not found: %s", name) 168 + } 169 + 170 + var targetURI string 171 + if version == "latest" { 172 + // Sort by version (semver) 173 + sort.Slice(matches, func(i, j int) bool { 174 + return compareVersions(matches[i].version, matches[j].version) > 0 175 + }) 176 + targetURI = matches[0].uri 177 + } else { 178 + // Find exact version 179 + for _, m := range matches { 180 + if m.version == version { 181 + targetURI = m.uri 182 + break 183 + } 184 + } 185 + if targetURI == "" { 186 + versions := make([]string, len(matches)) 187 + for i, m := range matches { 188 + versions[i] = m.version 189 + } 190 + return "", fmt.Errorf("version %s not found. Available: %s", version, strings.Join(versions, ", ")) 191 + } 192 + } 193 + 194 + // Cache the resolution 195 + c.mu.Lock() 196 + c.versionCache[cacheKey] = targetURI 197 + c.mu.Unlock() 198 + 199 + return targetURI, nil 200 + } 201 + 202 + // fetchBundle fetches a bundle by its AT URI 203 + func (c *BundleClient) fetchBundle(atURI string) (*Bundle, error) { 204 + // Check cache (skip in dev mode) 205 + if !c.devMode { 206 + c.mu.RLock() 207 + if bundle, ok := c.bundleCache[atURI]; ok { 208 + c.mu.RUnlock() 209 + // Verify the code file still exists 210 + if _, err := os.Stat(bundle.CodePath); err == nil { 211 + return bundle, nil 212 + } 213 + } 214 + c.mu.RUnlock() 215 + } 216 + 217 + // Parse AT URI: at://did/collection/rkey 218 + parts := parseATURI(atURI) 219 + if parts == nil { 220 + return nil, fmt.Errorf("invalid AT URI: %s", atURI) 221 + } 222 + 223 + // Get PDS client 224 + pds, err := c.getPDSClient(parts.DID) 225 + if err != nil { 226 + return nil, err 227 + } 228 + 229 + // Fetch the record 230 + record, err := pds.GetRecord(parts.DID, parts.Collection, parts.RKey) 231 + if err != nil { 232 + return nil, fmt.Errorf("failed to fetch bundle record: %w", err) 233 + } 234 + 235 + // Parse the bundle record 236 + var bundleRecord BundleRecord 237 + if err := json.Unmarshal(record.Value, &bundleRecord); err != nil { 238 + return nil, fmt.Errorf("failed to parse bundle record: %w", err) 239 + } 240 + 241 + if bundleRecord.Blob == nil || bundleRecord.Blob.Ref.Link == "" { 242 + return nil, fmt.Errorf("bundle record has no blob reference") 243 + } 244 + 245 + // Fetch the blob 246 + blobData, err := pds.GetBlob(parts.DID, bundleRecord.Blob.Ref.Link) 247 + if err != nil { 248 + return nil, fmt.Errorf("failed to fetch bundle code: %w", err) 249 + } 250 + 251 + // Save to cache directory 252 + codePath, err := c.saveBundle(atURI, blobData) 253 + if err != nil { 254 + return nil, fmt.Errorf("failed to cache bundle: %w", err) 255 + } 256 + 257 + bundle := &Bundle{ 258 + URI: atURI, 259 + DID: parts.DID, 260 + Name: bundleRecord.Name, 261 + Version: bundleRecord.Version, 262 + Runtime: bundleRecord.Runtime, 263 + Permissions: bundleRecord.Permissions, 264 + CodePath: codePath, 265 + } 266 + 267 + // Cache the bundle 268 + c.mu.Lock() 269 + c.bundleCache[atURI] = bundle 270 + c.mu.Unlock() 271 + 272 + return bundle, nil 273 + } 274 + 275 + // saveBundle saves bundle code to the cache directory 276 + func (c *BundleClient) saveBundle(atURI string, code []byte) (string, error) { 277 + // Create a hash-based filename 278 + hash := sha256.Sum256([]byte(atURI)) 279 + filename := fmt.Sprintf("bundle-%x.js", hash[:8]) 280 + codePath := filepath.Join(c.cacheDir, filename) 281 + 282 + // Ensure cache directory exists 283 + if err := os.MkdirAll(c.cacheDir, 0755); err != nil { 284 + return "", err 285 + } 286 + 287 + // Write the code 288 + if err := os.WriteFile(codePath, code, 0644); err != nil { 289 + return "", err 290 + } 291 + 292 + return codePath, nil 293 + } 294 + 295 + // getPDSClient returns a PDS client for a DID 296 + func (c *BundleClient) getPDSClient(did string) (*PDSClient, error) { 297 + // Resolve DID to get PDS URL 298 + doc, err := c.didResolver.Resolve(did) 299 + if err != nil { 300 + return nil, fmt.Errorf("failed to resolve DID: %w", err) 301 + } 302 + 303 + pdsURL, err := doc.GetPDSURL() 304 + if err != nil { 305 + return nil, err 306 + } 307 + 308 + // Get or create cached client 309 + c.mu.Lock() 310 + defer c.mu.Unlock() 311 + 312 + if client, ok := c.pdsClientCache[pdsURL]; ok { 313 + return client, nil 314 + } 315 + 316 + client := NewPDSClient(pdsURL) 317 + c.pdsClientCache[pdsURL] = client 318 + return client, nil 319 + } 320 + 321 + // ATURIParts holds parsed AT URI components 322 + type ATURIParts struct { 323 + DID string 324 + Collection string 325 + RKey string 326 + } 327 + 328 + // parseATURI parses an AT URI into its components 329 + func parseATURI(uri string) *ATURIParts { 330 + // at://did:plc:xxx/collection/rkey 331 + if !strings.HasPrefix(uri, "at://") { 332 + return nil 333 + } 334 + 335 + uri = strings.TrimPrefix(uri, "at://") 336 + parts := strings.SplitN(uri, "/", 3) 337 + if len(parts) != 3 { 338 + return nil 339 + } 340 + 341 + return &ATURIParts{ 342 + DID: parts[0], 343 + Collection: parts[1], 344 + RKey: parts[2], 345 + } 346 + } 347 + 348 + // compareVersions compares two semver strings 349 + // Returns: >0 if a > b, <0 if a < b, 0 if equal 350 + func compareVersions(a, b string) int { 351 + parseVersion := func(v string) []int { 352 + // Strip any suffix after numbers (e.g., "1.2.3-beta" -> [1, 2, 3]) 353 + re := regexp.MustCompile(`^(\d+)(?:\.(\d+))?(?:\.(\d+))?`) 354 + matches := re.FindStringSubmatch(v) 355 + if matches == nil { 356 + return []int{0, 0, 0} 357 + } 358 + 359 + result := make([]int, 3) 360 + for i := 1; i < len(matches) && i <= 3; i++ { 361 + if matches[i] != "" { 362 + result[i-1], _ = strconv.Atoi(matches[i]) 363 + } 364 + } 365 + return result 366 + } 367 + 368 + av := parseVersion(a) 369 + bv := parseVersion(b) 370 + 371 + for i := 0; i < 3; i++ { 372 + if av[i] != bv[i] { 373 + return av[i] - bv[i] 374 + } 375 + } 376 + return 0 377 + } 378 + 379 + // ClearCache clears the bundle cache 380 + func (c *BundleClient) ClearCache() { 381 + c.mu.Lock() 382 + defer c.mu.Unlock() 383 + 384 + c.bundleCache = make(map[string]*Bundle) 385 + c.versionCache = make(map[string]string) 386 + } 387 + 388 + // CacheStats returns cache statistics 389 + func (c *BundleClient) CacheStats() (bundles, versions int) { 390 + c.mu.RLock() 391 + defer c.mu.RUnlock() 392 + return len(c.bundleCache), len(c.versionCache) 393 + }
+159
internal/atproto/did.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + "time" 9 + ) 10 + 11 + // DIDDocument represents an AT Protocol DID document 12 + type DIDDocument struct { 13 + Context []string `json:"@context"` 14 + ID string `json:"id"` 15 + AlsoKnownAs []string `json:"alsoKnownAs"` 16 + VerificationMethod []struct { 17 + ID string `json:"id"` 18 + Type string `json:"type"` 19 + Controller string `json:"controller"` 20 + PublicKeyMultibase string `json:"publicKeyMultibase"` 21 + } `json:"verificationMethod"` 22 + Service []Service `json:"service"` 23 + } 24 + 25 + type Service struct { 26 + ID string `json:"id"` 27 + Type string `json:"type"` 28 + ServiceEndpoint string `json:"serviceEndpoint"` 29 + } 30 + 31 + // DIDResolver resolves DIDs to their documents 32 + type DIDResolver struct { 33 + httpClient *http.Client 34 + plcURL string 35 + cache map[string]*cachedDoc 36 + } 37 + 38 + type cachedDoc struct { 39 + doc *DIDDocument 40 + expiresAt time.Time 41 + } 42 + 43 + const ( 44 + defaultPLCURL = "https://plc.directory" 45 + cacheDuration = 5 * time.Minute 46 + ) 47 + 48 + func NewDIDResolver() *DIDResolver { 49 + return &DIDResolver{ 50 + httpClient: &http.Client{Timeout: 10 * time.Second}, 51 + plcURL: defaultPLCURL, 52 + cache: make(map[string]*cachedDoc), 53 + } 54 + } 55 + 56 + // Resolve resolves a DID to its document 57 + func (r *DIDResolver) Resolve(did string) (*DIDDocument, error) { 58 + // Check cache 59 + if cached, ok := r.cache[did]; ok && time.Now().Before(cached.expiresAt) { 60 + return cached.doc, nil 61 + } 62 + 63 + var doc *DIDDocument 64 + var err error 65 + 66 + if strings.HasPrefix(did, "did:plc:") { 67 + doc, err = r.resolvePLC(did) 68 + } else if strings.HasPrefix(did, "did:web:") { 69 + doc, err = r.resolveWeb(did) 70 + } else { 71 + return nil, fmt.Errorf("unsupported DID method: %s", did) 72 + } 73 + 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + // Cache the result 79 + r.cache[did] = &cachedDoc{ 80 + doc: doc, 81 + expiresAt: time.Now().Add(cacheDuration), 82 + } 83 + 84 + return doc, nil 85 + } 86 + 87 + // resolvePLC resolves a did:plc DID via plc.directory 88 + func (r *DIDResolver) resolvePLC(did string) (*DIDDocument, error) { 89 + url := fmt.Sprintf("%s/%s", r.plcURL, did) 90 + 91 + resp, err := r.httpClient.Get(url) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to fetch DID document: %w", err) 94 + } 95 + defer resp.Body.Close() 96 + 97 + if resp.StatusCode != http.StatusOK { 98 + return nil, fmt.Errorf("DID not found: %s (status %d)", did, resp.StatusCode) 99 + } 100 + 101 + var doc DIDDocument 102 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 103 + return nil, fmt.Errorf("failed to parse DID document: %w", err) 104 + } 105 + 106 + return &doc, nil 107 + } 108 + 109 + // resolveWeb resolves a did:web DID 110 + func (r *DIDResolver) resolveWeb(did string) (*DIDDocument, error) { 111 + // did:web:example.com -> https://example.com/.well-known/did.json 112 + // did:web:example.com:path:to -> https://example.com/path/to/did.json 113 + domain := strings.TrimPrefix(did, "did:web:") 114 + domain = strings.ReplaceAll(domain, ":", "/") 115 + 116 + var url string 117 + if strings.Contains(domain, "/") { 118 + url = fmt.Sprintf("https://%s/did.json", domain) 119 + } else { 120 + url = fmt.Sprintf("https://%s/.well-known/did.json", domain) 121 + } 122 + 123 + resp, err := r.httpClient.Get(url) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to fetch DID document: %w", err) 126 + } 127 + defer resp.Body.Close() 128 + 129 + if resp.StatusCode != http.StatusOK { 130 + return nil, fmt.Errorf("DID not found: %s (status %d)", did, resp.StatusCode) 131 + } 132 + 133 + var doc DIDDocument 134 + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 135 + return nil, fmt.Errorf("failed to parse DID document: %w", err) 136 + } 137 + 138 + return &doc, nil 139 + } 140 + 141 + // GetPDSURL extracts the PDS service endpoint from a DID document 142 + func (doc *DIDDocument) GetPDSURL() (string, error) { 143 + for _, svc := range doc.Service { 144 + if svc.ID == "#atproto_pds" || svc.ID == doc.ID+"#atproto_pds" { 145 + return svc.ServiceEndpoint, nil 146 + } 147 + } 148 + return "", fmt.Errorf("no PDS service found in DID document") 149 + } 150 + 151 + // GetHandle extracts the handle from alsoKnownAs 152 + func (doc *DIDDocument) GetHandle() string { 153 + for _, aka := range doc.AlsoKnownAs { 154 + if strings.HasPrefix(aka, "at://") { 155 + return strings.TrimPrefix(aka, "at://") 156 + } 157 + } 158 + return "" 159 + }
+126
internal/atproto/pds.go
··· 1 + package atproto 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + "time" 10 + ) 11 + 12 + // PDSClient is a client for AT Protocol PDS APIs 13 + type PDSClient struct { 14 + httpClient *http.Client 15 + baseURL string 16 + } 17 + 18 + func NewPDSClient(baseURL string) *PDSClient { 19 + return &PDSClient{ 20 + httpClient: &http.Client{Timeout: 30 * time.Second}, 21 + baseURL: baseURL, 22 + } 23 + } 24 + 25 + // GetRecordResponse is the response from com.atproto.repo.getRecord 26 + type GetRecordResponse struct { 27 + URI string `json:"uri"` 28 + CID string `json:"cid"` 29 + Value json.RawMessage `json:"value"` 30 + } 31 + 32 + // ListRecordsResponse is the response from com.atproto.repo.listRecords 33 + type ListRecordsResponse struct { 34 + Records []struct { 35 + URI string `json:"uri"` 36 + CID string `json:"cid"` 37 + Value json.RawMessage `json:"value"` 38 + } `json:"records"` 39 + Cursor string `json:"cursor,omitempty"` 40 + } 41 + 42 + // GetRecord fetches a single record from the PDS 43 + func (c *PDSClient) GetRecord(repo, collection, rkey string) (*GetRecordResponse, error) { 44 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 45 + c.baseURL, 46 + url.QueryEscape(repo), 47 + url.QueryEscape(collection), 48 + url.QueryEscape(rkey), 49 + ) 50 + 51 + resp, err := c.httpClient.Get(url) 52 + if err != nil { 53 + return nil, fmt.Errorf("failed to fetch record: %w", err) 54 + } 55 + defer resp.Body.Close() 56 + 57 + if resp.StatusCode != http.StatusOK { 58 + body, _ := io.ReadAll(resp.Body) 59 + return nil, fmt.Errorf("failed to fetch record (status %d): %s", resp.StatusCode, string(body)) 60 + } 61 + 62 + var result GetRecordResponse 63 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 64 + return nil, fmt.Errorf("failed to parse record: %w", err) 65 + } 66 + 67 + return &result, nil 68 + } 69 + 70 + // ListRecords lists records in a collection 71 + func (c *PDSClient) ListRecords(repo, collection string, limit int, cursor string) (*ListRecordsResponse, error) { 72 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", 73 + c.baseURL, 74 + url.QueryEscape(repo), 75 + url.QueryEscape(collection), 76 + limit, 77 + ) 78 + if cursor != "" { 79 + url += "&cursor=" + url 80 + } 81 + 82 + resp, err := c.httpClient.Get(url) 83 + if err != nil { 84 + return nil, fmt.Errorf("failed to list records: %w", err) 85 + } 86 + defer resp.Body.Close() 87 + 88 + if resp.StatusCode != http.StatusOK { 89 + body, _ := io.ReadAll(resp.Body) 90 + return nil, fmt.Errorf("failed to list records (status %d): %s", resp.StatusCode, string(body)) 91 + } 92 + 93 + var result ListRecordsResponse 94 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 95 + return nil, fmt.Errorf("failed to parse records: %w", err) 96 + } 97 + 98 + return &result, nil 99 + } 100 + 101 + // GetBlob fetches a blob from the PDS 102 + func (c *PDSClient) GetBlob(did, cid string) ([]byte, error) { 103 + url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 104 + c.baseURL, 105 + url.QueryEscape(did), 106 + url.QueryEscape(cid), 107 + ) 108 + 109 + resp, err := c.httpClient.Get(url) 110 + if err != nil { 111 + return nil, fmt.Errorf("failed to fetch blob: %w", err) 112 + } 113 + defer resp.Body.Close() 114 + 115 + if resp.StatusCode != http.StatusOK { 116 + body, _ := io.ReadAll(resp.Body) 117 + return nil, fmt.Errorf("failed to fetch blob (status %d): %s", resp.StatusCode, string(body)) 118 + } 119 + 120 + data, err := io.ReadAll(resp.Body) 121 + if err != nil { 122 + return nil, fmt.Errorf("failed to read blob: %w", err) 123 + } 124 + 125 + return data, nil 126 + }
+101
internal/cli/build.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + 9 + "github.com/spf13/cobra" 10 + ) 11 + 12 + var ( 13 + buildRuntime string 14 + ) 15 + 16 + var buildCmd = &cobra.Command{ 17 + Use: "build", 18 + Short: "Build Firecracker runtime images", 19 + Long: `Builds Firecracker kernel and rootfs images from Nix configurations.`, 20 + RunE: runBuild, 21 + } 22 + 23 + func init() { 24 + buildCmd.Flags().StringVarP(&buildRuntime, "runtime", "r", "", "build specific runtime (default: all)") 25 + } 26 + 27 + func runBuild(cmd *cobra.Command, args []string) error { 28 + dir := configDir() 29 + runtimesDir := filepath.Join(dir, "runtimes") 30 + imagesDir := filepath.Join(dir, "images") 31 + 32 + // Check nix is available 33 + if _, err := exec.LookPath("nix"); err != nil { 34 + return fmt.Errorf("nix not found in PATH. Install from https://nixos.org/download") 35 + } 36 + 37 + // Find runtimes to build 38 + var runtimes []string 39 + if buildRuntime != "" { 40 + runtimes = []string{buildRuntime} 41 + } else { 42 + entries, err := os.ReadDir(runtimesDir) 43 + if err != nil { 44 + return fmt.Errorf("failed to read runtimes directory: %w", err) 45 + } 46 + for _, e := range entries { 47 + if filepath.Ext(e.Name()) == ".nix" { 48 + name := e.Name()[:len(e.Name())-4] // strip .nix 49 + runtimes = append(runtimes, name) 50 + } 51 + } 52 + } 53 + 54 + if len(runtimes) == 0 { 55 + return fmt.Errorf("no runtimes found in %s", runtimesDir) 56 + } 57 + 58 + fmt.Printf("Building %d runtime(s): %v\n\n", len(runtimes), runtimes) 59 + 60 + for _, runtime := range runtimes { 61 + fmt.Printf("Building %s...\n", runtime) 62 + 63 + outputDir := filepath.Join(imagesDir, runtime) 64 + if err := os.MkdirAll(outputDir, 0755); err != nil { 65 + return fmt.Errorf("failed to create output dir: %w", err) 66 + } 67 + 68 + // TODO: Actually invoke nix build with the runtime config 69 + // For now, just stub it out 70 + if err := buildRuntimeImage(runtime, runtimesDir, outputDir); err != nil { 71 + return fmt.Errorf("failed to build %s: %w", runtime, err) 72 + } 73 + 74 + fmt.Printf(" ✓ Built %s -> %s\n", runtime, outputDir) 75 + } 76 + 77 + fmt.Println("\nBuild complete.") 78 + return nil 79 + } 80 + 81 + func buildRuntimeImage(runtime, runtimesDir, outputDir string) error { 82 + // This will invoke nix build with the appropriate flake 83 + // For now, create placeholder files to indicate structure 84 + 85 + nixFile := filepath.Join(runtimesDir, runtime+".nix") 86 + if _, err := os.Stat(nixFile); os.IsNotExist(err) { 87 + return fmt.Errorf("runtime config not found: %s", nixFile) 88 + } 89 + 90 + // TODO: Implement actual nix build 91 + // nix build .#firecracker-${runtime} -o ${outputDir} 92 + // 93 + // The flake would produce: 94 + // - kernel: Linux kernel image 95 + // - rootfs.ext4: Root filesystem with runtime + guest agent 96 + 97 + fmt.Printf(" [stub] Would build from %s\n", nixFile) 98 + fmt.Printf(" [stub] Output: %s/kernel, %s/rootfs.ext4\n", outputDir, outputDir) 99 + 100 + return nil 101 + }
+196
internal/cli/init.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + 8 + "github.com/spf13/cobra" 9 + ) 10 + 11 + var initCmd = &cobra.Command{ 12 + Use: "init", 13 + Short: "Initialize at-rund configuration", 14 + Long: `Creates ~/.at-rund/ with default configuration and runtime definitions.`, 15 + RunE: runInit, 16 + } 17 + 18 + func runInit(cmd *cobra.Command, args []string) error { 19 + dir := configDir() 20 + 21 + // Create directory structure 22 + dirs := []string{ 23 + dir, 24 + filepath.Join(dir, "runtimes"), 25 + filepath.Join(dir, "images"), 26 + filepath.Join(dir, "bundles"), 27 + filepath.Join(dir, "keys"), 28 + } 29 + 30 + for _, d := range dirs { 31 + if err := os.MkdirAll(d, 0755); err != nil { 32 + return fmt.Errorf("failed to create %s: %w", d, err) 33 + } 34 + fmt.Printf("Created %s\n", d) 35 + } 36 + 37 + // Write default config 38 + configPath := filepath.Join(dir, "config.toml") 39 + if _, err := os.Stat(configPath); os.IsNotExist(err) { 40 + if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil { 41 + return fmt.Errorf("failed to write config: %w", err) 42 + } 43 + fmt.Printf("Created %s\n", configPath) 44 + } else { 45 + fmt.Printf("Skipped %s (already exists)\n", configPath) 46 + } 47 + 48 + // Write default runtime configs 49 + runtimes := map[string]string{ 50 + "deno.nix": denoRuntime, 51 + "node.nix": nodeRuntime, 52 + "python.nix": pythonRuntime, 53 + } 54 + 55 + for name, content := range runtimes { 56 + path := filepath.Join(dir, "runtimes", name) 57 + if _, err := os.Stat(path); os.IsNotExist(err) { 58 + if err := os.WriteFile(path, []byte(content), 0644); err != nil { 59 + return fmt.Errorf("failed to write %s: %w", name, err) 60 + } 61 + fmt.Printf("Created %s\n", path) 62 + } else { 63 + fmt.Printf("Skipped %s (already exists)\n", path) 64 + } 65 + } 66 + 67 + fmt.Println("\nInitialized at-rund. Next steps:") 68 + fmt.Println(" 1. Edit ~/.at-rund/config.toml to set your DID") 69 + fmt.Println(" 2. Run 'at-rund build' to build runtime images") 70 + fmt.Println(" 3. Run 'at-rund serve' to start the server") 71 + 72 + return nil 73 + } 74 + 75 + var defaultConfig = `# at-rund configuration 76 + # 77 + # Your runner represents YOU on the network. Bundle authors trust your runner 78 + # because they trust you. Configure it to reflect your identity and policies. 79 + 80 + # Your identity (the runner IS you) 81 + # did = "did:plc:your-did-here" 82 + # handle = "you.bsky.social" 83 + 84 + # Server settings 85 + port = 3000 86 + dev_mode = false 87 + 88 + [pool] 89 + # VMs to keep warm per runtime (prod mode only) 90 + pre_warm = 2 91 + # Maximum total VMs across all runtimes 92 + max_vms = 20 93 + # Reclaim idle VMs after this duration 94 + idle_timeout = "5m" 95 + # Maximum time to wait for VM boot 96 + spawn_timeout = "30s" 97 + 98 + [runtimes] 99 + # Runtime name = path to .nix file (relative to ~/.at-rund/runtimes/) 100 + # Write your own configs or use the examples as a starting point 101 + deno = "deno.nix" 102 + # node = "node.nix" 103 + # python = "python.nix" 104 + 105 + [access] 106 + # Access control mode: "open", "allowlist", or "blocklist" 107 + mode = "open" 108 + 109 + # If mode = "allowlist", only these DIDs can run bundles 110 + # allowlist = ["did:plc:friend1", "did:plc:friend2"] 111 + 112 + # If mode = "blocklist", block specific DIDs 113 + # blocklist = ["did:plc:spammer"] 114 + 115 + [limits] 116 + # Per-request resource limits 117 + max_execution_time = "30s" 118 + max_memory_mb = 512 119 + 120 + [observability] 121 + # OpenTelemetry endpoint for metrics/traces 122 + # otlp_endpoint = "http://localhost:4317" 123 + 124 + # Log format: "json" or "text" 125 + log_format = "text" 126 + ` 127 + 128 + var denoRuntime = `# Deno runtime configuration 129 + { pkgs, ... }: 130 + { 131 + mimeTypes = [ 132 + "application/javascript+deno" 133 + "application/javascript+deno-atrun" 134 + "application/typescript+deno" 135 + ]; 136 + 137 + guest = { 138 + environment.systemPackages = with pkgs; [ 139 + deno 140 + ]; 141 + }; 142 + 143 + executor = { 144 + command = "deno run"; 145 + permissionFlags = { 146 + net = "--allow-net"; 147 + read = "--allow-read"; 148 + write = "--allow-write"; 149 + env = "--allow-env"; 150 + }; 151 + }; 152 + } 153 + ` 154 + 155 + var nodeRuntime = `# Node.js runtime configuration 156 + { pkgs, ... }: 157 + { 158 + mimeTypes = [ 159 + "application/javascript+node" 160 + "application/javascript" 161 + ]; 162 + 163 + guest = { 164 + environment.systemPackages = with pkgs; [ 165 + nodejs_22 166 + ]; 167 + }; 168 + 169 + executor = { 170 + command = "node"; 171 + permissionFlags = {}; # Node doesn't have permission flags 172 + }; 173 + } 174 + ` 175 + 176 + var pythonRuntime = `# Python runtime configuration 177 + { pkgs, ... }: 178 + { 179 + mimeTypes = [ 180 + "application/python" 181 + "application/x-python" 182 + ]; 183 + 184 + guest = { 185 + environment.systemPackages = with pkgs; [ 186 + python312 187 + python312Packages.pip 188 + ]; 189 + }; 190 + 191 + executor = { 192 + command = "python3"; 193 + permissionFlags = {}; 194 + }; 195 + } 196 + `
+55
internal/cli/pool.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var poolCmd = &cobra.Command{ 10 + Use: "pool", 11 + Short: "Manage VM pool", 12 + } 13 + 14 + var poolStatusCmd = &cobra.Command{ 15 + Use: "status", 16 + Short: "Show VM pool status", 17 + RunE: runPoolStatus, 18 + } 19 + 20 + var poolWarmCmd = &cobra.Command{ 21 + Use: "warm", 22 + Short: "Pre-warm VMs", 23 + RunE: runPoolWarm, 24 + } 25 + 26 + var poolDrainCmd = &cobra.Command{ 27 + Use: "drain", 28 + Short: "Gracefully drain all VMs", 29 + RunE: runPoolDrain, 30 + } 31 + 32 + func init() { 33 + poolCmd.AddCommand(poolStatusCmd) 34 + poolCmd.AddCommand(poolWarmCmd) 35 + poolCmd.AddCommand(poolDrainCmd) 36 + } 37 + 38 + func runPoolStatus(cmd *cobra.Command, args []string) error { 39 + // TODO: Connect to running at-rund instance and query pool status 40 + fmt.Println("Pool status requires a running at-rund server.") 41 + fmt.Println("This will query the server's /pool/status endpoint.") 42 + return nil 43 + } 44 + 45 + func runPoolWarm(cmd *cobra.Command, args []string) error { 46 + // TODO: Connect to running at-rund instance and warm pool 47 + fmt.Println("Pool warm requires a running at-rund server.") 48 + return nil 49 + } 50 + 51 + func runPoolDrain(cmd *cobra.Command, args []string) error { 52 + // TODO: Connect to running at-rund instance and drain pool 53 + fmt.Println("Pool drain requires a running at-rund server.") 54 + return nil 55 + }
+46
internal/cli/root.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/spf13/cobra" 8 + ) 9 + 10 + var ( 11 + cfgFile string 12 + verbose bool 13 + ) 14 + 15 + var rootCmd = &cobra.Command{ 16 + Use: "at-rund", 17 + Short: "AT Protocol serverless runtime daemon", 18 + Long: `at-rund is the infrastructure daemon for hosting AT Protocol serverless bundles. 19 + 20 + It manages Firecracker microVMs, builds runtime images via Nix, and executes 21 + bundles in isolated environments with virtio-fs mounting.`, 22 + } 23 + 24 + func Execute() error { 25 + return rootCmd.Execute() 26 + } 27 + 28 + func init() { 29 + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.at-rund/config.toml)") 30 + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") 31 + 32 + rootCmd.AddCommand(initCmd) 33 + rootCmd.AddCommand(buildCmd) 34 + rootCmd.AddCommand(serveCmd) 35 + rootCmd.AddCommand(runtimeCmd) 36 + rootCmd.AddCommand(poolCmd) 37 + } 38 + 39 + func configDir() string { 40 + home, err := os.UserHomeDir() 41 + if err != nil { 42 + fmt.Fprintf(os.Stderr, "error: cannot find home directory: %v\n", err) 43 + os.Exit(1) 44 + } 45 + return home + "/.at-rund" 46 + }
+65
internal/cli/runtime.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "text/tabwriter" 8 + 9 + "github.com/spf13/cobra" 10 + ) 11 + 12 + var runtimeCmd = &cobra.Command{ 13 + Use: "runtime", 14 + Short: "Manage runtime configurations", 15 + } 16 + 17 + var runtimeListCmd = &cobra.Command{ 18 + Use: "list", 19 + Short: "List available runtimes", 20 + RunE: runRuntimeList, 21 + } 22 + 23 + func init() { 24 + runtimeCmd.AddCommand(runtimeListCmd) 25 + } 26 + 27 + func runRuntimeList(cmd *cobra.Command, args []string) error { 28 + dir := configDir() 29 + runtimesDir := filepath.Join(dir, "runtimes") 30 + imagesDir := filepath.Join(dir, "images") 31 + 32 + entries, err := os.ReadDir(runtimesDir) 33 + if err != nil { 34 + if os.IsNotExist(err) { 35 + fmt.Println("No runtimes configured. Run 'at-rund init' first.") 36 + return nil 37 + } 38 + return err 39 + } 40 + 41 + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 42 + fmt.Fprintln(w, "RUNTIME\tCONFIG\tIMAGE\tSTATUS") 43 + 44 + for _, e := range entries { 45 + if filepath.Ext(e.Name()) != ".nix" { 46 + continue 47 + } 48 + 49 + name := e.Name()[:len(e.Name())-4] 50 + configPath := filepath.Join(runtimesDir, e.Name()) 51 + imagePath := filepath.Join(imagesDir, name) 52 + 53 + status := "not built" 54 + if _, err := os.Stat(filepath.Join(imagePath, "rootfs.ext4")); err == nil { 55 + status = "ready" 56 + } else if _, err := os.Stat(imagePath); err == nil { 57 + status = "partial" 58 + } 59 + 60 + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, configPath, imagePath, status) 61 + } 62 + 63 + w.Flush() 64 + return nil 65 + }
+125
internal/cli/serve.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/signal" 7 + "syscall" 8 + 9 + "github.com/neutrino2211/at-rund/internal/atproto" 10 + "github.com/neutrino2211/at-rund/internal/config" 11 + "github.com/neutrino2211/at-rund/internal/server" 12 + "github.com/neutrino2211/at-rund/internal/vm" 13 + "github.com/spf13/cobra" 14 + ) 15 + 16 + var ( 17 + servePort int 18 + serveDev bool 19 + serveMock bool 20 + ) 21 + 22 + var serveCmd = &cobra.Command{ 23 + Use: "serve", 24 + Short: "Start the at-rund server", 25 + Long: `Starts the HTTP server and initializes the Firecracker VM pool.`, 26 + RunE: runServe, 27 + } 28 + 29 + func init() { 30 + serveCmd.Flags().IntVarP(&servePort, "port", "p", 0, "port to listen on (default: from config)") 31 + serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable development mode") 32 + serveCmd.Flags().BoolVar(&serveMock, "mock", false, "use mock VMs instead of Firecracker (auto-enabled if KVM unavailable)") 33 + } 34 + 35 + func kvmAvailable() bool { 36 + // Check if /dev/kvm exists and is accessible 37 + _, err := os.Stat("/dev/kvm") 38 + return err == nil 39 + } 40 + 41 + func runServe(cmd *cobra.Command, args []string) error { 42 + // Load config 43 + cfg, err := config.Load(cfgFile) 44 + if err != nil { 45 + return fmt.Errorf("failed to load config: %w", err) 46 + } 47 + 48 + // Override with flags 49 + if servePort != 0 { 50 + cfg.Port = servePort 51 + } 52 + if serveDev { 53 + cfg.DevMode = true 54 + } 55 + 56 + fmt.Printf("at-rund starting...\n") 57 + fmt.Printf(" Port: %d\n", cfg.Port) 58 + fmt.Printf(" Dev mode: %v\n", cfg.DevMode) 59 + if cfg.DID != "" { 60 + fmt.Printf(" DID: %s\n", cfg.DID) 61 + } 62 + 63 + // Initialize executor based on mode 64 + var executor vm.Executor 65 + 66 + if cfg.DevMode || !kvmAvailable() { 67 + // Dev mode: use Nix directly (no VM isolation) 68 + if !kvmAvailable() && !cfg.DevMode { 69 + fmt.Println(" KVM not available, using Nix executor") 70 + } 71 + fmt.Println(" Executor: Nix (direct execution)") 72 + 73 + flakePath := vm.GetFlakePath() 74 + executor, err = vm.NewNixPool(flakePath) 75 + if err != nil { 76 + fmt.Println("\n Nix is required for dev mode. Install it with:") 77 + fmt.Println(" curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install") 78 + fmt.Println("") 79 + return fmt.Errorf("failed to initialize Nix executor: %w", err) 80 + } 81 + } else { 82 + // Prod mode: use Firecracker VMs 83 + fmt.Println(" Executor: Firecracker (VM isolation)") 84 + 85 + executor, err = vm.NewFirecrackerPool(nil) 86 + if err != nil { 87 + return fmt.Errorf("failed to initialize Firecracker pool: %w", err) 88 + } 89 + } 90 + defer executor.Shutdown() 91 + 92 + // Pre-warm if configured 93 + if cfg.Pool.PreWarm > 0 { 94 + fmt.Printf(" Pre-warming runtimes...\n") 95 + if err := executor.Warm(cfg.Pool.PreWarm); err != nil { 96 + fmt.Printf(" Warning: failed to pre-warm: %v\n", err) 97 + } 98 + } 99 + 100 + // Initialize bundle client 101 + bundleClient := atproto.NewBundleClient(cfg.BundlesDir(), cfg.DevMode) 102 + fmt.Println(" Bundle client: ready") 103 + 104 + // Start HTTP server 105 + srv := server.New(cfg, executor, bundleClient) 106 + 107 + // Handle shutdown gracefully 108 + sigCh := make(chan os.Signal, 1) 109 + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 110 + 111 + errCh := make(chan error, 1) 112 + go func() { 113 + fmt.Printf("\nListening on http://localhost:%d\n", cfg.Port) 114 + errCh <- srv.ListenAndServe() 115 + }() 116 + 117 + select { 118 + case err := <-errCh: 119 + return err 120 + case sig := <-sigCh: 121 + fmt.Printf("\nReceived %s, shutting down...\n", sig) 122 + executor.Drain() 123 + return srv.Shutdown() 124 + } 125 + }
+228
internal/cli/systemd.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "strings" 9 + 10 + "github.com/spf13/cobra" 11 + ) 12 + 13 + var systemdCmd = &cobra.Command{ 14 + Use: "systemd", 15 + Short: "Manage systemd service", 16 + } 17 + 18 + var systemdInstallCmd = &cobra.Command{ 19 + Use: "install", 20 + Short: "Install systemd service unit", 21 + Long: `Generates and installs a systemd service unit for at-rund.`, 22 + RunE: runSystemdInstall, 23 + } 24 + 25 + var systemdUninstallCmd = &cobra.Command{ 26 + Use: "uninstall", 27 + Short: "Uninstall systemd service unit", 28 + RunE: runSystemdUninstall, 29 + } 30 + 31 + var systemdStatusCmd = &cobra.Command{ 32 + Use: "status", 33 + Short: "Show service status", 34 + RunE: runSystemdStatus, 35 + } 36 + 37 + var ( 38 + systemdUser bool 39 + ) 40 + 41 + func init() { 42 + systemdCmd.AddCommand(systemdInstallCmd) 43 + systemdCmd.AddCommand(systemdUninstallCmd) 44 + systemdCmd.AddCommand(systemdStatusCmd) 45 + 46 + systemdInstallCmd.Flags().BoolVar(&systemdUser, "user", false, "install as user service (no sudo required)") 47 + 48 + rootCmd.AddCommand(systemdCmd) 49 + } 50 + 51 + func runSystemdInstall(cmd *cobra.Command, args []string) error { 52 + // Find the at-rund binary 53 + execPath, err := os.Executable() 54 + if err != nil { 55 + return fmt.Errorf("failed to find executable: %w", err) 56 + } 57 + execPath, err = filepath.Abs(execPath) 58 + if err != nil { 59 + return fmt.Errorf("failed to resolve path: %w", err) 60 + } 61 + 62 + // Get user info 63 + home, err := os.UserHomeDir() 64 + if err != nil { 65 + return fmt.Errorf("failed to get home dir: %w", err) 66 + } 67 + 68 + user := os.Getenv("USER") 69 + if user == "" { 70 + user = "nobody" 71 + } 72 + 73 + configPath := filepath.Join(home, ".at-rund", "config.toml") 74 + 75 + unit := generateUnit(execPath, configPath, user, home) 76 + 77 + var unitPath string 78 + if systemdUser { 79 + // User service 80 + unitDir := filepath.Join(home, ".config", "systemd", "user") 81 + if err := os.MkdirAll(unitDir, 0755); err != nil { 82 + return fmt.Errorf("failed to create systemd user dir: %w", err) 83 + } 84 + unitPath = filepath.Join(unitDir, "at-rund.service") 85 + } else { 86 + // System service 87 + unitPath = "/etc/systemd/system/at-rund.service" 88 + } 89 + 90 + if systemdUser { 91 + if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil { 92 + return fmt.Errorf("failed to write unit file: %w", err) 93 + } 94 + } else { 95 + // Need sudo for system service 96 + tmpFile := filepath.Join(os.TempDir(), "at-rund.service") 97 + if err := os.WriteFile(tmpFile, []byte(unit), 0644); err != nil { 98 + return fmt.Errorf("failed to write temp unit file: %w", err) 99 + } 100 + defer os.Remove(tmpFile) 101 + 102 + sudoCmd := exec.Command("sudo", "cp", tmpFile, unitPath) 103 + sudoCmd.Stdout = os.Stdout 104 + sudoCmd.Stderr = os.Stderr 105 + if err := sudoCmd.Run(); err != nil { 106 + return fmt.Errorf("failed to install unit file (try --user for user service): %w", err) 107 + } 108 + } 109 + 110 + fmt.Printf("Installed service unit: %s\n", unitPath) 111 + 112 + // Reload systemd 113 + var reloadCmd *exec.Cmd 114 + if systemdUser { 115 + reloadCmd = exec.Command("systemctl", "--user", "daemon-reload") 116 + } else { 117 + reloadCmd = exec.Command("sudo", "systemctl", "daemon-reload") 118 + } 119 + if err := reloadCmd.Run(); err != nil { 120 + fmt.Printf("Warning: failed to reload systemd: %v\n", err) 121 + } 122 + 123 + fmt.Println("\nTo start the service:") 124 + if systemdUser { 125 + fmt.Println(" systemctl --user start at-rund") 126 + fmt.Println(" systemctl --user enable at-rund # start on login") 127 + } else { 128 + fmt.Println(" sudo systemctl start at-rund") 129 + fmt.Println(" sudo systemctl enable at-rund # start on boot") 130 + } 131 + 132 + return nil 133 + } 134 + 135 + func runSystemdUninstall(cmd *cobra.Command, args []string) error { 136 + home, _ := os.UserHomeDir() 137 + 138 + var unitPath string 139 + var stopCmd, disableCmd, removeCmd *exec.Cmd 140 + 141 + // Check user service first 142 + userPath := filepath.Join(home, ".config", "systemd", "user", "at-rund.service") 143 + systemPath := "/etc/systemd/system/at-rund.service" 144 + 145 + if _, err := os.Stat(userPath); err == nil { 146 + unitPath = userPath 147 + stopCmd = exec.Command("systemctl", "--user", "stop", "at-rund") 148 + disableCmd = exec.Command("systemctl", "--user", "disable", "at-rund") 149 + removeCmd = exec.Command("rm", userPath) 150 + } else if _, err := os.Stat(systemPath); err == nil { 151 + unitPath = systemPath 152 + stopCmd = exec.Command("sudo", "systemctl", "stop", "at-rund") 153 + disableCmd = exec.Command("sudo", "systemctl", "disable", "at-rund") 154 + removeCmd = exec.Command("sudo", "rm", systemPath) 155 + } else { 156 + return fmt.Errorf("no at-rund service found") 157 + } 158 + 159 + fmt.Printf("Stopping service...\n") 160 + stopCmd.Run() // Ignore errors 161 + 162 + fmt.Printf("Disabling service...\n") 163 + disableCmd.Run() // Ignore errors 164 + 165 + fmt.Printf("Removing %s...\n", unitPath) 166 + if err := removeCmd.Run(); err != nil { 167 + return fmt.Errorf("failed to remove unit file: %w", err) 168 + } 169 + 170 + fmt.Println("Service uninstalled.") 171 + return nil 172 + } 173 + 174 + func runSystemdStatus(cmd *cobra.Command, args []string) error { 175 + home, _ := os.UserHomeDir() 176 + 177 + userPath := filepath.Join(home, ".config", "systemd", "user", "at-rund.service") 178 + systemPath := "/etc/systemd/system/at-rund.service" 179 + 180 + var statusCmd *exec.Cmd 181 + if _, err := os.Stat(userPath); err == nil { 182 + statusCmd = exec.Command("systemctl", "--user", "status", "at-rund") 183 + } else if _, err := os.Stat(systemPath); err == nil { 184 + statusCmd = exec.Command("systemctl", "status", "at-rund") 185 + } else { 186 + return fmt.Errorf("no at-rund service installed") 187 + } 188 + 189 + statusCmd.Stdout = os.Stdout 190 + statusCmd.Stderr = os.Stderr 191 + statusCmd.Run() // Ignore exit code (non-zero if service not running) 192 + return nil 193 + } 194 + 195 + func generateUnit(execPath, configPath, user, home string) string { 196 + return strings.TrimSpace(fmt.Sprintf(` 197 + [Unit] 198 + Description=at-rund - AT Protocol serverless runtime daemon 199 + Documentation=https://github.com/neutrino2211/at-rund 200 + After=network-online.target 201 + Wants=network-online.target 202 + 203 + [Service] 204 + Type=simple 205 + User=%s 206 + Group=%s 207 + ExecStart=%s serve --config %s 208 + Restart=on-failure 209 + RestartSec=5 210 + 211 + # Environment 212 + Environment="HOME=%s" 213 + Environment="PATH=/usr/local/bin:/usr/bin:/bin" 214 + 215 + # Security hardening (optional, adjust as needed) 216 + NoNewPrivileges=true 217 + ProtectSystem=strict 218 + ProtectHome=read-only 219 + ReadWritePaths=%s/.at-rund 220 + 221 + # Resource limits 222 + MemoryMax=4G 223 + TasksMax=100 224 + 225 + [Install] 226 + WantedBy=multi-user.target 227 + `, user, user, execPath, configPath, home, home)) 228 + }
+149
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "time" 8 + 9 + "github.com/pelletier/go-toml/v2" 10 + ) 11 + 12 + type Config struct { 13 + DID string `toml:"did"` 14 + Handle string `toml:"handle"` // For display (e.g., "alice.bsky.social") 15 + Port int `toml:"port"` 16 + DevMode bool `toml:"dev_mode"` 17 + 18 + Pool PoolConfig `toml:"pool"` 19 + Runtimes map[string]string `toml:"runtimes"` 20 + Access AccessConfig `toml:"access"` 21 + Limits LimitsConfig `toml:"limits"` 22 + Observability ObservabilityConfig `toml:"observability"` 23 + } 24 + 25 + type LimitsConfig struct { 26 + MaxExecutionTime string `toml:"max_execution_time"` 27 + MaxMemoryMB int `toml:"max_memory_mb"` 28 + 29 + // Parsed duration 30 + MaxExecutionDuration time.Duration `toml:"-"` 31 + } 32 + 33 + type ObservabilityConfig struct { 34 + OTLPEndpoint string `toml:"otlp_endpoint"` 35 + LogFormat string `toml:"log_format"` // "json" or "text" 36 + } 37 + 38 + type PoolConfig struct { 39 + PreWarm int `toml:"pre_warm"` 40 + MaxVMs int `toml:"max_vms"` 41 + IdleTimeout string `toml:"idle_timeout"` 42 + SpawnTimeout string `toml:"spawn_timeout"` 43 + 44 + // Parsed durations (populated after load) 45 + IdleTimeoutDuration time.Duration `toml:"-"` 46 + SpawnTimeoutDuration time.Duration `toml:"-"` 47 + } 48 + 49 + type AccessConfig struct { 50 + Mode string `toml:"mode"` // "open", "allowlist", "blocklist" 51 + Allowlist []string `toml:"allowlist"` // DIDs allowed (if mode = "allowlist") 52 + Blocklist []string `toml:"blocklist"` // DIDs blocked (if mode = "blocklist") 53 + } 54 + 55 + // IsAllowed checks if a DID is allowed to use this runner 56 + func (a *AccessConfig) IsAllowed(did string) bool { 57 + switch a.Mode { 58 + case "allowlist": 59 + for _, allowed := range a.Allowlist { 60 + if allowed == did { 61 + return true 62 + } 63 + } 64 + return false 65 + case "blocklist": 66 + for _, blocked := range a.Blocklist { 67 + if blocked == did { 68 + return false 69 + } 70 + } 71 + return true 72 + default: // "open" or unset 73 + return true 74 + } 75 + } 76 + 77 + func Load(path string) (*Config, error) { 78 + if path == "" { 79 + home, err := os.UserHomeDir() 80 + if err != nil { 81 + return nil, err 82 + } 83 + path = filepath.Join(home, ".at-rund", "config.toml") 84 + } 85 + 86 + data, err := os.ReadFile(path) 87 + if err != nil { 88 + if os.IsNotExist(err) { 89 + return defaultConfig(), nil 90 + } 91 + return nil, fmt.Errorf("failed to read config: %w", err) 92 + } 93 + 94 + cfg := defaultConfig() 95 + if err := toml.Unmarshal(data, cfg); err != nil { 96 + return nil, fmt.Errorf("failed to parse config: %w", err) 97 + } 98 + 99 + // Parse duration strings 100 + if cfg.Pool.IdleTimeout != "" { 101 + d, err := time.ParseDuration(cfg.Pool.IdleTimeout) 102 + if err != nil { 103 + return nil, fmt.Errorf("invalid idle_timeout: %w", err) 104 + } 105 + cfg.Pool.IdleTimeoutDuration = d 106 + } 107 + if cfg.Pool.SpawnTimeout != "" { 108 + d, err := time.ParseDuration(cfg.Pool.SpawnTimeout) 109 + if err != nil { 110 + return nil, fmt.Errorf("invalid spawn_timeout: %w", err) 111 + } 112 + cfg.Pool.SpawnTimeoutDuration = d 113 + } 114 + 115 + return cfg, nil 116 + } 117 + 118 + func defaultConfig() *Config { 119 + return &Config{ 120 + Port: 3000, 121 + DevMode: false, 122 + Pool: PoolConfig{ 123 + PreWarm: 2, 124 + MaxVMs: 20, 125 + IdleTimeout: "5m", 126 + SpawnTimeout: "30s", 127 + IdleTimeoutDuration: 5 * time.Minute, 128 + SpawnTimeoutDuration: 30 * time.Second, 129 + }, 130 + Runtimes: map[string]string{ 131 + "deno": "deno.nix", 132 + }, 133 + } 134 + } 135 + 136 + func (c *Config) RuntimesDir() string { 137 + home, _ := os.UserHomeDir() 138 + return filepath.Join(home, ".at-rund", "runtimes") 139 + } 140 + 141 + func (c *Config) ImagesDir() string { 142 + home, _ := os.UserHomeDir() 143 + return filepath.Join(home, ".at-rund", "images") 144 + } 145 + 146 + func (c *Config) BundlesDir() string { 147 + home, _ := os.UserHomeDir() 148 + return filepath.Join(home, ".at-rund", "bundles") 149 + }
+179
internal/observability/logger.go
··· 1 + package observability 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "os" 9 + "sync" 10 + "time" 11 + ) 12 + 13 + type LogLevel string 14 + 15 + const ( 16 + LevelDebug LogLevel = "debug" 17 + LevelInfo LogLevel = "info" 18 + LevelWarn LogLevel = "warn" 19 + LevelError LogLevel = "error" 20 + ) 21 + 22 + type Logger struct { 23 + mu sync.Mutex 24 + output io.Writer 25 + format string // "json" or "text" 26 + minLevel LogLevel 27 + } 28 + 29 + type LogEntry struct { 30 + Timestamp string `json:"timestamp"` 31 + Level LogLevel `json:"level"` 32 + Message string `json:"message"` 33 + TraceID string `json:"traceId,omitempty"` 34 + Fields map[string]interface{} `json:"fields,omitempty"` 35 + } 36 + 37 + var defaultLogger = &Logger{ 38 + output: os.Stdout, 39 + format: "text", 40 + minLevel: LevelInfo, 41 + } 42 + 43 + func SetLogger(l *Logger) { 44 + defaultLogger = l 45 + } 46 + 47 + func NewLogger(output io.Writer, format string, minLevel LogLevel) *Logger { 48 + return &Logger{ 49 + output: output, 50 + format: format, 51 + minLevel: minLevel, 52 + } 53 + } 54 + 55 + func (l *Logger) log(level LogLevel, msg string, traceID string, fields map[string]interface{}) { 56 + if !l.shouldLog(level) { 57 + return 58 + } 59 + 60 + entry := LogEntry{ 61 + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), 62 + Level: level, 63 + Message: msg, 64 + TraceID: traceID, 65 + Fields: fields, 66 + } 67 + 68 + l.mu.Lock() 69 + defer l.mu.Unlock() 70 + 71 + if l.format == "json" { 72 + data, _ := json.Marshal(entry) 73 + fmt.Fprintln(l.output, string(data)) 74 + } else { 75 + // Text format 76 + if traceID != "" { 77 + fmt.Fprintf(l.output, "%s [%s] [%s] %s", entry.Timestamp, entry.Level, traceID, entry.Message) 78 + } else { 79 + fmt.Fprintf(l.output, "%s [%s] %s", entry.Timestamp, entry.Level, entry.Message) 80 + } 81 + if len(fields) > 0 { 82 + data, _ := json.Marshal(fields) 83 + fmt.Fprintf(l.output, " %s", string(data)) 84 + } 85 + fmt.Fprintln(l.output) 86 + } 87 + } 88 + 89 + func (l *Logger) shouldLog(level LogLevel) bool { 90 + levels := map[LogLevel]int{ 91 + LevelDebug: 0, 92 + LevelInfo: 1, 93 + LevelWarn: 2, 94 + LevelError: 3, 95 + } 96 + return levels[level] >= levels[l.minLevel] 97 + } 98 + 99 + // Context-aware logging 100 + type ctxKey string 101 + 102 + const traceIDKey ctxKey = "traceID" 103 + 104 + func WithTraceID(ctx context.Context, traceID string) context.Context { 105 + return context.WithValue(ctx, traceIDKey, traceID) 106 + } 107 + 108 + func TraceIDFromContext(ctx context.Context) string { 109 + if id, ok := ctx.Value(traceIDKey).(string); ok { 110 + return id 111 + } 112 + return "" 113 + } 114 + 115 + // Package-level logging functions 116 + func Debug(msg string, fields ...map[string]interface{}) { 117 + var f map[string]interface{} 118 + if len(fields) > 0 { 119 + f = fields[0] 120 + } 121 + defaultLogger.log(LevelDebug, msg, "", f) 122 + } 123 + 124 + func Info(msg string, fields ...map[string]interface{}) { 125 + var f map[string]interface{} 126 + if len(fields) > 0 { 127 + f = fields[0] 128 + } 129 + defaultLogger.log(LevelInfo, msg, "", f) 130 + } 131 + 132 + func Warn(msg string, fields ...map[string]interface{}) { 133 + var f map[string]interface{} 134 + if len(fields) > 0 { 135 + f = fields[0] 136 + } 137 + defaultLogger.log(LevelWarn, msg, "", f) 138 + } 139 + 140 + func Error(msg string, fields ...map[string]interface{}) { 141 + var f map[string]interface{} 142 + if len(fields) > 0 { 143 + f = fields[0] 144 + } 145 + defaultLogger.log(LevelError, msg, "", f) 146 + } 147 + 148 + // Context-aware versions 149 + func DebugCtx(ctx context.Context, msg string, fields ...map[string]interface{}) { 150 + var f map[string]interface{} 151 + if len(fields) > 0 { 152 + f = fields[0] 153 + } 154 + defaultLogger.log(LevelDebug, msg, TraceIDFromContext(ctx), f) 155 + } 156 + 157 + func InfoCtx(ctx context.Context, msg string, fields ...map[string]interface{}) { 158 + var f map[string]interface{} 159 + if len(fields) > 0 { 160 + f = fields[0] 161 + } 162 + defaultLogger.log(LevelInfo, msg, TraceIDFromContext(ctx), f) 163 + } 164 + 165 + func WarnCtx(ctx context.Context, msg string, fields ...map[string]interface{}) { 166 + var f map[string]interface{} 167 + if len(fields) > 0 { 168 + f = fields[0] 169 + } 170 + defaultLogger.log(LevelWarn, msg, TraceIDFromContext(ctx), f) 171 + } 172 + 173 + func ErrorCtx(ctx context.Context, msg string, fields ...map[string]interface{}) { 174 + var f map[string]interface{} 175 + if len(fields) > 0 { 176 + f = fields[0] 177 + } 178 + defaultLogger.log(LevelError, msg, TraceIDFromContext(ctx), f) 179 + }
+188
internal/observability/metrics.go
··· 1 + package observability 2 + 3 + import ( 4 + "encoding/json" 5 + "sync" 6 + "sync/atomic" 7 + "time" 8 + ) 9 + 10 + type Metrics struct { 11 + mu sync.RWMutex 12 + 13 + // Request counters 14 + RequestsTotal atomic.Int64 15 + RequestsSuccess atomic.Int64 16 + RequestsFailed atomic.Int64 17 + 18 + // Timing 19 + requestLatencies []int64 // milliseconds 20 + latencyMu sync.Mutex 21 + 22 + // Per-bundle stats 23 + bundleStats map[string]*BundleStats 24 + 25 + // Runtime stats 26 + runtimeStats map[string]*RuntimeStats 27 + 28 + // Server start time 29 + StartTime time.Time 30 + } 31 + 32 + type BundleStats struct { 33 + DID string `json:"did"` 34 + Name string `json:"name"` 35 + Invocations int64 `json:"invocations"` 36 + Successes int64 `json:"successes"` 37 + Failures int64 `json:"failures"` 38 + TotalTimeMs int64 `json:"totalTimeMs"` 39 + LastInvoked string `json:"lastInvoked"` 40 + } 41 + 42 + type RuntimeStats struct { 43 + Name string `json:"name"` 44 + Invocations int64 `json:"invocations"` 45 + Successes int64 `json:"successes"` 46 + Failures int64 `json:"failures"` 47 + TotalTimeMs int64 `json:"totalTimeMs"` 48 + } 49 + 50 + type MetricsSnapshot struct { 51 + Uptime string `json:"uptime"` 52 + RequestsTotal int64 `json:"requestsTotal"` 53 + RequestsSuccess int64 `json:"requestsSuccess"` 54 + RequestsFailed int64 `json:"requestsFailed"` 55 + LatencyP50Ms int64 `json:"latencyP50Ms"` 56 + LatencyP95Ms int64 `json:"latencyP95Ms"` 57 + LatencyP99Ms int64 `json:"latencyP99Ms"` 58 + Bundles map[string]*BundleStats `json:"bundles"` 59 + Runtimes map[string]*RuntimeStats `json:"runtimes"` 60 + } 61 + 62 + var globalMetrics = &Metrics{ 63 + bundleStats: make(map[string]*BundleStats), 64 + runtimeStats: make(map[string]*RuntimeStats), 65 + StartTime: time.Now(), 66 + } 67 + 68 + func GetMetrics() *Metrics { 69 + return globalMetrics 70 + } 71 + 72 + func (m *Metrics) RecordRequest(did, bundleName, runtime string, durationMs int64, success bool) { 73 + m.RequestsTotal.Add(1) 74 + if success { 75 + m.RequestsSuccess.Add(1) 76 + } else { 77 + m.RequestsFailed.Add(1) 78 + } 79 + 80 + // Record latency 81 + m.latencyMu.Lock() 82 + m.requestLatencies = append(m.requestLatencies, durationMs) 83 + if len(m.requestLatencies) > 10000 { 84 + m.requestLatencies = m.requestLatencies[len(m.requestLatencies)-10000:] 85 + } 86 + m.latencyMu.Unlock() 87 + 88 + // Update bundle stats 89 + if did != "" && bundleName != "" { 90 + m.mu.Lock() 91 + key := did + "/" + bundleName 92 + stats, ok := m.bundleStats[key] 93 + if !ok { 94 + stats = &BundleStats{DID: did, Name: bundleName} 95 + m.bundleStats[key] = stats 96 + } 97 + stats.Invocations++ 98 + stats.TotalTimeMs += durationMs 99 + stats.LastInvoked = time.Now().UTC().Format(time.RFC3339) 100 + if success { 101 + stats.Successes++ 102 + } else { 103 + stats.Failures++ 104 + } 105 + m.mu.Unlock() 106 + } 107 + 108 + // Update runtime stats 109 + if runtime != "" { 110 + m.mu.Lock() 111 + rs, ok := m.runtimeStats[runtime] 112 + if !ok { 113 + rs = &RuntimeStats{Name: runtime} 114 + m.runtimeStats[runtime] = rs 115 + } 116 + rs.Invocations++ 117 + rs.TotalTimeMs += durationMs 118 + if success { 119 + rs.Successes++ 120 + } else { 121 + rs.Failures++ 122 + } 123 + m.mu.Unlock() 124 + } 125 + } 126 + 127 + func (m *Metrics) Snapshot() MetricsSnapshot { 128 + m.mu.RLock() 129 + defer m.mu.RUnlock() 130 + 131 + snapshot := MetricsSnapshot{ 132 + Uptime: time.Since(m.StartTime).Round(time.Second).String(), 133 + RequestsTotal: m.RequestsTotal.Load(), 134 + RequestsSuccess: m.RequestsSuccess.Load(), 135 + RequestsFailed: m.RequestsFailed.Load(), 136 + Bundles: make(map[string]*BundleStats), 137 + Runtimes: make(map[string]*RuntimeStats), 138 + } 139 + 140 + // Copy bundle stats 141 + for k, v := range m.bundleStats { 142 + copy := *v 143 + snapshot.Bundles[k] = &copy 144 + } 145 + 146 + // Copy runtime stats 147 + for k, v := range m.runtimeStats { 148 + copy := *v 149 + snapshot.Runtimes[k] = &copy 150 + } 151 + 152 + // Calculate percentiles 153 + m.latencyMu.Lock() 154 + if len(m.requestLatencies) > 0 { 155 + sorted := make([]int64, len(m.requestLatencies)) 156 + copy(sorted, m.requestLatencies) 157 + sortInt64s(sorted) 158 + snapshot.LatencyP50Ms = percentile(sorted, 50) 159 + snapshot.LatencyP95Ms = percentile(sorted, 95) 160 + snapshot.LatencyP99Ms = percentile(sorted, 99) 161 + } 162 + m.latencyMu.Unlock() 163 + 164 + return snapshot 165 + } 166 + 167 + func (m *Metrics) JSON() ([]byte, error) { 168 + return json.MarshalIndent(m.Snapshot(), "", " ") 169 + } 170 + 171 + func sortInt64s(a []int64) { 172 + for i := 1; i < len(a); i++ { 173 + for j := i; j > 0 && a[j-1] > a[j]; j-- { 174 + a[j-1], a[j] = a[j], a[j-1] 175 + } 176 + } 177 + } 178 + 179 + func percentile(sorted []int64, p int) int64 { 180 + if len(sorted) == 0 { 181 + return 0 182 + } 183 + idx := (len(sorted) * p) / 100 184 + if idx >= len(sorted) { 185 + idx = len(sorted) - 1 186 + } 187 + return sorted[idx] 188 + }
+336
internal/server/routes.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/google/uuid" 12 + "github.com/neutrino2211/at-rund/internal/atproto" 13 + "github.com/neutrino2211/at-rund/internal/observability" 14 + "github.com/neutrino2211/at-rund/internal/vm" 15 + ) 16 + 17 + func (s *Server) registerRoutes(mux *http.ServeMux) { 18 + mux.HandleFunc("/", s.handleIndex) 19 + mux.HandleFunc("/health", s.handleHealth) 20 + mux.HandleFunc("/bundle/", s.withTracing(s.handleBundle)) 21 + mux.HandleFunc("/at:/", s.withTracing(s.handleAtUri)) 22 + mux.HandleFunc("/pool/status", s.handlePoolStatus) 23 + mux.HandleFunc("/metrics", s.handleMetrics) 24 + } 25 + 26 + func (s *Server) withTracing(next http.HandlerFunc) http.HandlerFunc { 27 + return func(w http.ResponseWriter, r *http.Request) { 28 + traceID := r.Header.Get("X-Trace-ID") 29 + if traceID == "" { 30 + traceID = uuid.New().String()[:8] 31 + } 32 + ctx := observability.WithTraceID(r.Context(), traceID) 33 + w.Header().Set("X-Trace-ID", traceID) 34 + next(w, r.WithContext(ctx)) 35 + } 36 + } 37 + 38 + func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { 39 + if r.URL.Path != "/" { 40 + http.NotFound(w, r) 41 + return 42 + } 43 + 44 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 45 + fmt.Fprintf(w, `<!DOCTYPE html> 46 + <html> 47 + <head> 48 + <title>at-rund</title> 49 + <style> 50 + body { font-family: monospace; background: #0a0a0a; color: #e5e5e5; padding: 2rem; } 51 + h1 { color: #3b82f6; } 52 + code { background: #1a1a1a; padding: 0.2rem 0.5rem; border-radius: 4px; } 53 + </style> 54 + </head> 55 + <body> 56 + <h1>at-rund</h1> 57 + <p>AT Protocol serverless runtime daemon</p> 58 + <h2>Endpoints</h2> 59 + <ul> 60 + <li><code>GET /health</code> - Health check</li> 61 + <li><code>GET/POST /bundle/:did/:name/:version/:endpoint</code> - Execute bundle</li> 62 + <li><code>GET/POST /at://did/collection/rkey/endpoint</code> - Execute by AT URI</li> 63 + <li><code>GET /pool/status</code> - VM pool status</li> 64 + <li><code>GET /metrics</code> - Request and execution metrics</li> 65 + </ul> 66 + <p>DID: %s</p> 67 + <p>Dev mode: %v</p> 68 + </body> 69 + </html>`, s.cfg.DID, s.cfg.DevMode) 70 + } 71 + 72 + func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { 73 + w.WriteHeader(http.StatusOK) 74 + w.Write([]byte("ok")) 75 + } 76 + 77 + func (s *Server) handleBundle(w http.ResponseWriter, r *http.Request) { 78 + // /bundle/:did/:name/:version/:endpoint 79 + path := strings.TrimPrefix(r.URL.Path, "/bundle/") 80 + parts := strings.SplitN(path, "/", 4) 81 + if len(parts) < 4 { 82 + jsonError(w, "invalid path: expected /bundle/:did/:name/:version/:endpoint", http.StatusBadRequest) 83 + return 84 + } 85 + 86 + did := parts[0] 87 + name := parts[1] 88 + version := parts[2] 89 + endpoint := parts[3] 90 + 91 + // Check access control 92 + if !s.isDIDAllowed(did) { 93 + jsonError(w, "access denied for this DID", http.StatusForbidden) 94 + return 95 + } 96 + 97 + // Fetch the bundle 98 + bundle, err := s.bundleClient.GetBundle(atproto.BundleRef{ 99 + Type: "named", 100 + DID: did, 101 + Name: name, 102 + Version: version, 103 + }) 104 + if err != nil { 105 + jsonError(w, fmt.Sprintf("failed to fetch bundle: %v", err), http.StatusNotFound) 106 + return 107 + } 108 + 109 + // Parse request args 110 + args, err := s.parseArgs(r) 111 + if err != nil { 112 + jsonError(w, fmt.Sprintf("failed to parse args: %v", err), http.StatusBadRequest) 113 + return 114 + } 115 + 116 + // Execute the bundle 117 + s.executeBundle(w, r, bundle, endpoint, args) 118 + } 119 + 120 + func (s *Server) handleAtUri(w http.ResponseWriter, r *http.Request) { 121 + // /at://did/collection/rkey/endpoint 122 + // The path comes as /at://did/collection/rkey/endpoint 123 + // We need to parse: at://did/collection/rkey and endpoint 124 + path := strings.TrimPrefix(r.URL.Path, "/at:/") 125 + if !strings.HasPrefix(path, "/") { 126 + path = "/" + path 127 + } 128 + 129 + // Path is now: /did/collection/rkey/endpoint 130 + // We need to split into AT URI (at://did/collection/rkey) and endpoint 131 + parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 4) 132 + if len(parts) < 4 { 133 + jsonError(w, "invalid path: expected /at://did/collection/rkey/endpoint", http.StatusBadRequest) 134 + return 135 + } 136 + 137 + did := parts[0] 138 + collection := parts[1] 139 + rkey := parts[2] 140 + endpoint := parts[3] 141 + atURI := fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) 142 + 143 + // Check access control 144 + if !s.isDIDAllowed(did) { 145 + jsonError(w, "access denied for this DID", http.StatusForbidden) 146 + return 147 + } 148 + 149 + // Fetch the bundle 150 + bundle, err := s.bundleClient.GetBundle(atproto.BundleRef{ 151 + Type: "at", 152 + URI: atURI, 153 + }) 154 + if err != nil { 155 + jsonError(w, fmt.Sprintf("failed to fetch bundle: %v", err), http.StatusNotFound) 156 + return 157 + } 158 + 159 + // Parse request args 160 + args, err := s.parseArgs(r) 161 + if err != nil { 162 + jsonError(w, fmt.Sprintf("failed to parse args: %v", err), http.StatusBadRequest) 163 + return 164 + } 165 + 166 + // Execute the bundle 167 + s.executeBundle(w, r, bundle, endpoint, args) 168 + } 169 + 170 + func (s *Server) handlePoolStatus(w http.ResponseWriter, r *http.Request) { 171 + stats := s.executor.Stats() 172 + jsonResponse(w, stats) 173 + } 174 + 175 + func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { 176 + data, err := observability.GetMetrics().JSON() 177 + if err != nil { 178 + jsonError(w, "failed to get metrics", http.StatusInternalServerError) 179 + return 180 + } 181 + w.Header().Set("Content-Type", "application/json") 182 + w.Write(data) 183 + } 184 + 185 + func (s *Server) isDIDAllowed(did string) bool { 186 + return s.cfg.Access.IsAllowed(did) 187 + } 188 + 189 + // parseArgs extracts arguments from the request (POST body or query params) 190 + func (s *Server) parseArgs(r *http.Request) (json.RawMessage, error) { 191 + if r.Method == http.MethodPost { 192 + body, err := io.ReadAll(r.Body) 193 + if err != nil { 194 + return nil, err 195 + } 196 + if len(body) == 0 { 197 + return json.RawMessage("{}"), nil 198 + } 199 + return json.RawMessage(body), nil 200 + } 201 + 202 + // GET: check for ?args= param first 203 + if argsParam := r.URL.Query().Get("args"); argsParam != "" { 204 + return json.RawMessage(argsParam), nil 205 + } 206 + 207 + // Fall back to converting query params to JSON object 208 + args := make(map[string]string) 209 + for key, values := range r.URL.Query() { 210 + if key != "args" && len(values) > 0 { 211 + args[key] = values[0] 212 + } 213 + } 214 + 215 + if len(args) == 0 { 216 + return json.RawMessage("{}"), nil 217 + } 218 + 219 + data, err := json.Marshal(args) 220 + if err != nil { 221 + return nil, err 222 + } 223 + return json.RawMessage(data), nil 224 + } 225 + 226 + // executeBundle runs the bundle endpoint and writes the response 227 + func (s *Server) executeBundle(w http.ResponseWriter, r *http.Request, bundle *atproto.Bundle, endpoint string, args json.RawMessage) { 228 + ctx := r.Context() 229 + start := time.Now() 230 + 231 + observability.InfoCtx(ctx, "executing bundle", map[string]any{ 232 + "did": bundle.DID, 233 + "name": bundle.Name, 234 + "endpoint": endpoint, 235 + "runtime": bundle.Runtime, 236 + }) 237 + 238 + // Convert bundle permissions to executor permissions 239 + perms := vm.Permissions{ 240 + Net: bundle.Permissions.Net, 241 + Read: bundle.Permissions.Read, 242 + Write: bundle.Permissions.Write, 243 + Env: bundle.Permissions.Env, 244 + } 245 + 246 + // Create execution request 247 + req := vm.ExecuteRequest{ 248 + BundlePath: bundle.CodePath, 249 + Endpoint: endpoint, 250 + Args: args, 251 + Env: make(map[string]string), // TODO: inject secrets 252 + Permissions: perms, 253 + TimeoutSecs: 30, // TODO: from config 254 + } 255 + 256 + // Execute 257 + resp, err := s.executor.Execute(req, bundle.Runtime) 258 + durationMs := time.Since(start).Milliseconds() 259 + 260 + if err != nil { 261 + observability.GetMetrics().RecordRequest(bundle.DID, bundle.Name, bundle.Runtime, durationMs, false) 262 + observability.ErrorCtx(ctx, "execution error", map[string]any{ 263 + "error": err.Error(), 264 + "duration": durationMs, 265 + }) 266 + jsonError(w, fmt.Sprintf("execution failed: %v", err), http.StatusInternalServerError) 267 + return 268 + } 269 + 270 + if !resp.Success { 271 + observability.GetMetrics().RecordRequest(bundle.DID, bundle.Name, bundle.Runtime, durationMs, false) 272 + observability.WarnCtx(ctx, "execution failed", map[string]any{ 273 + "error": resp.Error, 274 + "duration": durationMs, 275 + }) 276 + jsonError(w, resp.Error, http.StatusInternalServerError) 277 + return 278 + } 279 + 280 + observability.GetMetrics().RecordRequest(bundle.DID, bundle.Name, bundle.Runtime, durationMs, true) 281 + observability.InfoCtx(ctx, "execution complete", map[string]any{ 282 + "duration": durationMs, 283 + "success": true, 284 + }) 285 + 286 + // Handle HTTP response from bundle 287 + if resp.Response != nil { 288 + for key, value := range resp.Response.Headers { 289 + w.Header().Set(key, value) 290 + } 291 + w.WriteHeader(resp.Response.Status) 292 + w.Write([]byte(resp.Response.Body)) 293 + return 294 + } 295 + 296 + // Return JSON data 297 + w.Header().Set("Content-Type", "application/json") 298 + if resp.Data != nil { 299 + w.Write(resp.Data) 300 + } else { 301 + w.Write([]byte("null")) 302 + } 303 + } 304 + 305 + func jsonResponse(w http.ResponseWriter, data interface{}) { 306 + w.Header().Set("Content-Type", "application/json") 307 + json.NewEncoder(w).Encode(data) 308 + } 309 + 310 + func jsonError(w http.ResponseWriter, message string, status int) { 311 + w.Header().Set("Content-Type", "application/json") 312 + w.WriteHeader(status) 313 + json.NewEncoder(w).Encode(map[string]string{"error": message}) 314 + } 315 + 316 + func formatUptime(d time.Duration) string { 317 + days := int(d.Hours()) / 24 318 + hours := int(d.Hours()) % 24 319 + mins := int(d.Minutes()) % 60 320 + secs := int(d.Seconds()) % 60 321 + 322 + var parts []string 323 + if days > 0 { 324 + parts = append(parts, fmt.Sprintf("%dd", days)) 325 + } 326 + if hours > 0 { 327 + parts = append(parts, fmt.Sprintf("%dh", hours)) 328 + } 329 + if mins > 0 { 330 + parts = append(parts, fmt.Sprintf("%dm", mins)) 331 + } 332 + if secs > 0 || len(parts) == 0 { 333 + parts = append(parts, fmt.Sprintf("%ds", secs)) 334 + } 335 + return strings.Join(parts, " ") 336 + }
+50
internal/server/server.go
··· 1 + package server 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/neutrino2211/at-rund/internal/atproto" 10 + "github.com/neutrino2211/at-rund/internal/config" 11 + "github.com/neutrino2211/at-rund/internal/vm" 12 + ) 13 + 14 + type Server struct { 15 + cfg *config.Config 16 + executor vm.Executor 17 + bundleClient *atproto.BundleClient 18 + server *http.Server 19 + } 20 + 21 + func New(cfg *config.Config, executor vm.Executor, bundleClient *atproto.BundleClient) *Server { 22 + s := &Server{ 23 + cfg: cfg, 24 + executor: executor, 25 + bundleClient: bundleClient, 26 + } 27 + 28 + mux := http.NewServeMux() 29 + s.registerRoutes(mux) 30 + 31 + s.server = &http.Server{ 32 + Addr: fmt.Sprintf(":%d", cfg.Port), 33 + Handler: mux, 34 + ReadTimeout: 30 * time.Second, 35 + WriteTimeout: 60 * time.Second, 36 + IdleTimeout: 120 * time.Second, 37 + } 38 + 39 + return s 40 + } 41 + 42 + func (s *Server) ListenAndServe() error { 43 + return s.server.ListenAndServe() 44 + } 45 + 46 + func (s *Server) Shutdown() error { 47 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 48 + defer cancel() 49 + return s.server.Shutdown(ctx) 50 + }
+58
internal/vm/executor.go
··· 1 + package vm 2 + 3 + // Executor is the common interface for bundle execution backends 4 + // Implemented by NixPool (dev mode) and FirecrackerPool (prod mode) 5 + type Executor interface { 6 + // Execute runs a bundle and returns the result 7 + Execute(req ExecuteRequest, mimeType string) (*ExecuteResponse, error) 8 + 9 + // Stats returns pool statistics 10 + Stats() PoolStats 11 + 12 + // Warm pre-warms the executor (downloads nix packages or boots VMs) 13 + Warm(count int) error 14 + 15 + // Drain gracefully shuts down all running executions 16 + Drain() 17 + 18 + // Shutdown stops the executor 19 + Shutdown() 20 + } 21 + 22 + // Ensure implementations satisfy the interface 23 + var _ Executor = (*NixPool)(nil) 24 + var _ Executor = (*FirecrackerPool)(nil) 25 + 26 + // FirecrackerPool is the production executor using Firecracker VMs 27 + // TODO: Implement this 28 + type FirecrackerPool struct { 29 + pool *Pool 30 + } 31 + 32 + func NewFirecrackerPool(cfg *Config) (*FirecrackerPool, error) { 33 + // For now, wrap the existing Pool 34 + // This will be expanded with real Firecracker integration 35 + return &FirecrackerPool{}, nil 36 + } 37 + 38 + func (f *FirecrackerPool) Execute(req ExecuteRequest, mimeType string) (*ExecuteResponse, error) { 39 + return &ExecuteResponse{ 40 + Success: false, 41 + Error: "Firecracker execution not yet implemented", 42 + }, nil 43 + } 44 + 45 + func (f *FirecrackerPool) Stats() PoolStats { 46 + return PoolStats{ 47 + Runtimes: make(map[string]RuntimeStats), 48 + Total: TotalStats{Max: 0}, 49 + } 50 + } 51 + 52 + func (f *FirecrackerPool) Warm(count int) error { 53 + return nil 54 + } 55 + 56 + func (f *FirecrackerPool) Drain() {} 57 + 58 + func (f *FirecrackerPool) Shutdown() {}
+72
internal/vm/instance.go
··· 1 + package vm 2 + 3 + import ( 4 + "encoding/json" 5 + ) 6 + 7 + type Instance struct { 8 + ID string 9 + Runtime string 10 + 11 + // TODO: Add Firecracker process handle, vsock connection, etc. 12 + } 13 + 14 + type ExecuteRequest struct { 15 + BundlePath string `json:"bundlePath"` 16 + Endpoint string `json:"endpoint"` 17 + Args json.RawMessage `json:"args"` 18 + Env map[string]string `json:"env"` 19 + Permissions Permissions `json:"permissions"` 20 + TimeoutSecs int `json:"timeout"` 21 + } 22 + 23 + type ExecuteResponse struct { 24 + Success bool `json:"success"` 25 + Data json.RawMessage `json:"data,omitempty"` 26 + Response *HTTPResponse `json:"response,omitempty"` 27 + Error string `json:"error,omitempty"` 28 + Metrics *Metrics `json:"metrics,omitempty"` 29 + } 30 + 31 + type HTTPResponse struct { 32 + Status int `json:"status"` 33 + Headers map[string]string `json:"headers"` 34 + Body string `json:"body"` 35 + IsBase64 bool `json:"isBase64,omitempty"` 36 + } 37 + 38 + type Permissions struct { 39 + Net []string `json:"net,omitempty"` 40 + Read []string `json:"read,omitempty"` 41 + Write []string `json:"write,omitempty"` 42 + Env []string `json:"env,omitempty"` 43 + } 44 + 45 + type Metrics struct { 46 + MemoryUsedBytes uint64 `json:"memoryUsedBytes"` 47 + CPUPercent float64 `json:"cpuPercent"` 48 + ExecutionTimeMs int64 `json:"executionTimeMs"` 49 + } 50 + 51 + func (i *Instance) Execute(req ExecuteRequest) (*ExecuteResponse, error) { 52 + // TODO: Send request over vsock to guest agent 53 + return &ExecuteResponse{ 54 + Success: false, 55 + Error: "not implemented", 56 + }, nil 57 + } 58 + 59 + func (i *Instance) Stop() error { 60 + // TODO: Kill Firecracker process 61 + return nil 62 + } 63 + 64 + func (i *Instance) MountBundle(bundlePath string) error { 65 + // TODO: Mount bundle via virtio-fs 66 + return nil 67 + } 68 + 69 + func (i *Instance) UnmountBundle() error { 70 + // TODO: Unmount bundle 71 + return nil 72 + }
+162
internal/vm/mock.go
··· 1 + package vm 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os/exec" 7 + "time" 8 + ) 9 + 10 + // MockInstance simulates a Firecracker VM for local development 11 + // Used when KVM isn't available (e.g., macOS, nested virtualization) 12 + type MockInstance struct { 13 + ID string 14 + Runtime string 15 + Started time.Time 16 + } 17 + 18 + func NewMockInstance(id, runtime string) *MockInstance { 19 + return &MockInstance{ 20 + ID: id, 21 + Runtime: runtime, 22 + Started: time.Now(), 23 + } 24 + } 25 + 26 + func (m *MockInstance) Execute(req ExecuteRequest) (*ExecuteResponse, error) { 27 + start := time.Now() 28 + 29 + // Actually execute the bundle using the local runtime 30 + // This provides real execution without Firecracker isolation 31 + var cmd *exec.Cmd 32 + 33 + switch m.Runtime { 34 + case "deno": 35 + // Build Deno command with permissions 36 + args := []string{"run", "--quiet"} 37 + for _, host := range req.Permissions.Net { 38 + args = append(args, fmt.Sprintf("--allow-net=%s", host)) 39 + } 40 + for _, path := range req.Permissions.Read { 41 + args = append(args, fmt.Sprintf("--allow-read=%s", path)) 42 + } 43 + for _, path := range req.Permissions.Write { 44 + args = append(args, fmt.Sprintf("--allow-write=%s", path)) 45 + } 46 + if len(req.Permissions.Env) > 0 { 47 + args = append(args, "--allow-env") 48 + } 49 + args = append(args, req.BundlePath) 50 + cmd = exec.Command("deno", args...) 51 + 52 + case "node": 53 + cmd = exec.Command("node", req.BundlePath) 54 + 55 + case "python": 56 + cmd = exec.Command("python3", req.BundlePath) 57 + 58 + default: 59 + return &ExecuteResponse{ 60 + Success: false, 61 + Error: fmt.Sprintf("unsupported runtime: %s", m.Runtime), 62 + }, nil 63 + } 64 + 65 + // Set environment 66 + for k, v := range req.Env { 67 + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) 68 + } 69 + 70 + output, err := cmd.CombinedOutput() 71 + execTime := time.Since(start) 72 + 73 + if err != nil { 74 + return &ExecuteResponse{ 75 + Success: false, 76 + Error: fmt.Sprintf("%s: %s", err.Error(), string(output)), 77 + Metrics: &Metrics{ 78 + ExecutionTimeMs: execTime.Milliseconds(), 79 + }, 80 + }, nil 81 + } 82 + 83 + // Try to parse output as JSON 84 + var data json.RawMessage 85 + if err := json.Unmarshal(output, &data); err != nil { 86 + // Not JSON, return as string 87 + data, _ = json.Marshal(string(output)) 88 + } 89 + 90 + return &ExecuteResponse{ 91 + Success: true, 92 + Data: data, 93 + Metrics: &Metrics{ 94 + ExecutionTimeMs: execTime.Milliseconds(), 95 + }, 96 + }, nil 97 + } 98 + 99 + func (m *MockInstance) Stop() error { 100 + return nil 101 + } 102 + 103 + func (m *MockInstance) MountBundle(bundlePath string) error { 104 + // No-op in mock mode, bundle is accessed directly 105 + return nil 106 + } 107 + 108 + func (m *MockInstance) UnmountBundle() error { 109 + return nil 110 + } 111 + 112 + // MockPool is a VM pool that uses mock instances instead of real Firecracker VMs 113 + type MockPool struct { 114 + cfg *Config 115 + runtimes map[string]bool 116 + counter int 117 + } 118 + 119 + type Config struct { 120 + Runtimes map[string]string 121 + Pool struct { 122 + MaxVMs int 123 + } 124 + } 125 + 126 + func NewMockPool(runtimes map[string]string) *MockPool { 127 + rt := make(map[string]bool) 128 + for name := range runtimes { 129 + rt[name] = true 130 + } 131 + return &MockPool{ 132 + runtimes: rt, 133 + } 134 + } 135 + 136 + func (p *MockPool) Acquire(runtime string) (*MockInstance, error) { 137 + if !p.runtimes[runtime] { 138 + return nil, fmt.Errorf("runtime not configured: %s", runtime) 139 + } 140 + p.counter++ 141 + return NewMockInstance(fmt.Sprintf("mock-%s-%d", runtime, p.counter), runtime), nil 142 + } 143 + 144 + func (p *MockPool) Release(instance *MockInstance) { 145 + // No-op in mock mode 146 + } 147 + 148 + func (p *MockPool) Stats() PoolStats { 149 + stats := PoolStats{ 150 + Runtimes: make(map[string]RuntimeStats), 151 + Total: TotalStats{ 152 + Idle: 0, 153 + Busy: 0, 154 + Max: 100, 155 + }, 156 + } 157 + for rt := range p.runtimes { 158 + stats.Runtimes[rt] = RuntimeStats{Idle: 1, Busy: 0} 159 + stats.Total.Idle++ 160 + } 161 + return stats 162 + }
+267
internal/vm/nix.go
··· 1 + package vm 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "time" 11 + ) 12 + 13 + // NixExecutor runs bundles using Nix-provided runtimes 14 + // Each runtime defines its own executor (at-run-exec) that handles 15 + // permission translation and execution specifics 16 + type NixExecutor struct { 17 + runtimesDir string 18 + runtimes map[string]NixRuntime 19 + } 20 + 21 + type NixRuntime struct { 22 + Name string 23 + MimeTypes []string 24 + NixExpr string // Nix expression or flake reference 25 + } 26 + 27 + // ExecRequest is passed to the runtime's at-run-exec as JSON 28 + type ExecRequest struct { 29 + CodePath string `json:"codePath"` 30 + Endpoint string `json:"endpoint"` 31 + Args json.RawMessage `json:"args"` 32 + Permissions Permissions `json:"permissions"` 33 + Env map[string]string `json:"env"` 34 + Timeout int `json:"timeout"` // seconds 35 + } 36 + 37 + // ExecResponse is returned by at-run-exec as JSON 38 + type ExecResponse struct { 39 + Success bool `json:"success"` 40 + Data json.RawMessage `json:"data,omitempty"` 41 + Response *HTTPResponse `json:"response,omitempty"` 42 + Error string `json:"error,omitempty"` 43 + Metrics *ExecMetrics `json:"metrics,omitempty"` 44 + } 45 + 46 + type ExecMetrics struct { 47 + ExecutionTimeMs int64 `json:"executionTimeMs"` 48 + } 49 + 50 + // DefaultRuntimes returns built-in runtime configurations 51 + func DefaultRuntimes() map[string]NixRuntime { 52 + return map[string]NixRuntime{ 53 + "deno": { 54 + Name: "deno", 55 + MimeTypes: []string{"application/javascript+deno", "application/javascript+deno-atrun", "application/typescript+deno"}, 56 + NixExpr: "", // Will use runtimes dir 57 + }, 58 + "node": { 59 + Name: "node", 60 + MimeTypes: []string{"application/javascript+node", "application/javascript"}, 61 + NixExpr: "", 62 + }, 63 + "python": { 64 + Name: "python", 65 + MimeTypes: []string{"application/python", "application/x-python"}, 66 + NixExpr: "", 67 + }, 68 + } 69 + } 70 + 71 + func NewNixExecutor(runtimesDir string) (*NixExecutor, error) { 72 + // Check nix is available 73 + if _, err := exec.LookPath("nix"); err != nil { 74 + return nil, fmt.Errorf("nix not found in PATH: %w", err) 75 + } 76 + 77 + return &NixExecutor{ 78 + runtimesDir: runtimesDir, 79 + runtimes: DefaultRuntimes(), 80 + }, nil 81 + } 82 + 83 + func (n *NixExecutor) RuntimeForMimeType(mimeType string) (NixRuntime, bool) { 84 + for _, rt := range n.runtimes { 85 + for _, mt := range rt.MimeTypes { 86 + if mt == mimeType { 87 + return rt, true 88 + } 89 + } 90 + } 91 + return NixRuntime{}, false 92 + } 93 + 94 + func (n *NixExecutor) Execute(req ExecuteRequest, runtime NixRuntime) (*ExecuteResponse, error) { 95 + start := time.Now() 96 + 97 + // Build the exec request 98 + execReq := ExecRequest{ 99 + CodePath: req.BundlePath, 100 + Endpoint: req.Endpoint, 101 + Args: req.Args, 102 + Permissions: req.Permissions, 103 + Env: req.Env, 104 + Timeout: req.TimeoutSecs, 105 + } 106 + 107 + reqJSON, err := json.Marshal(execReq) 108 + if err != nil { 109 + return nil, fmt.Errorf("failed to marshal exec request: %w", err) 110 + } 111 + 112 + // Determine the nix expression to use 113 + nixExpr := n.getNixExpr(runtime) 114 + 115 + // Run: nix shell <expr> --command at-run-exec 116 + nixArgs := []string{ 117 + "shell", 118 + nixExpr, 119 + "--command", 120 + "at-run-exec", 121 + } 122 + 123 + cmd := exec.Command("nix", nixArgs...) 124 + cmd.Stdin = bytes.NewReader(reqJSON) 125 + cmd.Env = os.Environ() 126 + 127 + var stdout, stderr bytes.Buffer 128 + cmd.Stdout = &stdout 129 + cmd.Stderr = &stderr 130 + 131 + err = cmd.Run() 132 + execTime := time.Since(start) 133 + 134 + // Parse the response 135 + var execResp ExecResponse 136 + if err := json.Unmarshal(stdout.Bytes(), &execResp); err != nil { 137 + // If we can't parse the response, return the error 138 + errMsg := stderr.String() 139 + if errMsg == "" { 140 + errMsg = stdout.String() 141 + } 142 + if err != nil { 143 + errMsg = fmt.Sprintf("%s: %s", err.Error(), errMsg) 144 + } 145 + return &ExecuteResponse{ 146 + Success: false, 147 + Error: errMsg, 148 + Metrics: &Metrics{ExecutionTimeMs: execTime.Milliseconds()}, 149 + }, nil 150 + } 151 + 152 + // Convert ExecResponse to ExecuteResponse 153 + return &ExecuteResponse{ 154 + Success: execResp.Success, 155 + Data: execResp.Data, 156 + Response: execResp.Response, 157 + Error: execResp.Error, 158 + Metrics: &Metrics{ 159 + ExecutionTimeMs: execTime.Milliseconds(), 160 + }, 161 + }, nil 162 + } 163 + 164 + func (n *NixExecutor) getNixExpr(runtime NixRuntime) string { 165 + if runtime.NixExpr != "" { 166 + return runtime.NixExpr 167 + } 168 + 169 + // Look for runtime flake in runtimes dir 170 + if n.runtimesDir != "" { 171 + flakePath := filepath.Join(n.runtimesDir, runtime.Name, "flake.nix") 172 + if _, err := os.Stat(flakePath); err == nil { 173 + // Return path to the flake directory 174 + return filepath.Join(n.runtimesDir, runtime.Name) 175 + } 176 + } 177 + 178 + // Fallback: look in the binary's directory 179 + if exe, err := os.Executable(); err == nil { 180 + exeDir := filepath.Dir(exe) 181 + flakePath := filepath.Join(exeDir, "nix", "runtimes", runtime.Name, "flake.nix") 182 + if _, err := os.Stat(flakePath); err == nil { 183 + return filepath.Join(exeDir, "nix", "runtimes", runtime.Name) 184 + } 185 + } 186 + 187 + // No flake found - this runtime won't work without at-run-exec 188 + return "" 189 + } 190 + 191 + // NixPool implements the Executor interface using Nix runtimes 192 + type NixPool struct { 193 + executor *NixExecutor 194 + runtimesDir string 195 + } 196 + 197 + func NewNixPool(runtimesDir string) (*NixPool, error) { 198 + executor, err := NewNixExecutor(runtimesDir) 199 + if err != nil { 200 + return nil, err 201 + } 202 + 203 + return &NixPool{ 204 + executor: executor, 205 + runtimesDir: runtimesDir, 206 + }, nil 207 + } 208 + 209 + func (p *NixPool) Execute(req ExecuteRequest, mimeType string) (*ExecuteResponse, error) { 210 + runtime, ok := p.executor.RuntimeForMimeType(mimeType) 211 + if !ok { 212 + return nil, fmt.Errorf("no runtime for mime type: %s", mimeType) 213 + } 214 + return p.executor.Execute(req, runtime) 215 + } 216 + 217 + func (p *NixPool) Stats() PoolStats { 218 + stats := PoolStats{ 219 + Runtimes: make(map[string]RuntimeStats), 220 + Total: TotalStats{ 221 + Idle: len(p.executor.runtimes), 222 + Busy: 0, 223 + Max: 999, 224 + }, 225 + } 226 + for name := range p.executor.runtimes { 227 + stats.Runtimes[name] = RuntimeStats{Idle: 1, Busy: 0} 228 + } 229 + return stats 230 + } 231 + 232 + func (p *NixPool) Shutdown() {} 233 + 234 + func (p *NixPool) Drain() {} 235 + 236 + func (p *NixPool) Warm(count int) error { 237 + // Pre-build the nix expressions 238 + // This is optional but speeds up first execution 239 + return nil 240 + } 241 + 242 + // GetFlakePath returns the path to the nix runtimes directory 243 + func GetFlakePath() string { 244 + // Check local ./nix/runtimes (for development) 245 + if _, err := os.Stat("nix/runtimes"); err == nil { 246 + abs, _ := filepath.Abs("nix/runtimes") 247 + return abs 248 + } 249 + 250 + // Check relative to executable 251 + if exe, err := os.Executable(); err == nil { 252 + exeDir := filepath.Dir(exe) 253 + runtimesPath := filepath.Join(exeDir, "nix", "runtimes") 254 + if _, err := os.Stat(runtimesPath); err == nil { 255 + return runtimesPath 256 + } 257 + } 258 + 259 + // Check ~/.at-rund/runtimes 260 + home, _ := os.UserHomeDir() 261 + runtimesPath := filepath.Join(home, ".at-rund", "runtimes") 262 + if _, err := os.Stat(runtimesPath); err == nil { 263 + return runtimesPath 264 + } 265 + 266 + return "" 267 + }
+175
internal/vm/pool.go
··· 1 + package vm 2 + 3 + import ( 4 + "fmt" 5 + "sync" 6 + 7 + "github.com/neutrino2211/at-rund/internal/config" 8 + ) 9 + 10 + type Pool struct { 11 + cfg *config.Config 12 + mu sync.RWMutex 13 + 14 + // VMs by runtime name 15 + idle map[string][]*Instance 16 + busy map[string][]*Instance 17 + } 18 + 19 + type PoolStats struct { 20 + Runtimes map[string]RuntimeStats `json:"runtimes"` 21 + Total TotalStats `json:"total"` 22 + } 23 + 24 + type RuntimeStats struct { 25 + Idle int `json:"idle"` 26 + Busy int `json:"busy"` 27 + } 28 + 29 + type TotalStats struct { 30 + Idle int `json:"idle"` 31 + Busy int `json:"busy"` 32 + Max int `json:"max"` 33 + } 34 + 35 + func NewPool(cfg *config.Config) (*Pool, error) { 36 + p := &Pool{ 37 + cfg: cfg, 38 + idle: make(map[string][]*Instance), 39 + busy: make(map[string][]*Instance), 40 + } 41 + 42 + // Initialize empty pools for each configured runtime 43 + for runtime := range cfg.Runtimes { 44 + p.idle[runtime] = []*Instance{} 45 + p.busy[runtime] = []*Instance{} 46 + } 47 + 48 + return p, nil 49 + } 50 + 51 + func (p *Pool) Warm(count int) error { 52 + p.mu.Lock() 53 + defer p.mu.Unlock() 54 + 55 + for runtime := range p.cfg.Runtimes { 56 + for i := 0; i < count; i++ { 57 + instance, err := p.spawnInstance(runtime) 58 + if err != nil { 59 + return fmt.Errorf("failed to spawn %s VM: %w", runtime, err) 60 + } 61 + p.idle[runtime] = append(p.idle[runtime], instance) 62 + } 63 + } 64 + 65 + return nil 66 + } 67 + 68 + func (p *Pool) Acquire(runtime string) (*Instance, error) { 69 + p.mu.Lock() 70 + defer p.mu.Unlock() 71 + 72 + // Try to get an idle instance 73 + if len(p.idle[runtime]) > 0 { 74 + instance := p.idle[runtime][0] 75 + p.idle[runtime] = p.idle[runtime][1:] 76 + p.busy[runtime] = append(p.busy[runtime], instance) 77 + return instance, nil 78 + } 79 + 80 + // Check if we can spawn a new one 81 + total := p.totalVMs() 82 + if total >= p.cfg.Pool.MaxVMs { 83 + return nil, fmt.Errorf("VM pool exhausted (max: %d)", p.cfg.Pool.MaxVMs) 84 + } 85 + 86 + // Spawn new instance 87 + instance, err := p.spawnInstance(runtime) 88 + if err != nil { 89 + return nil, err 90 + } 91 + p.busy[runtime] = append(p.busy[runtime], instance) 92 + return instance, nil 93 + } 94 + 95 + func (p *Pool) Release(instance *Instance) { 96 + p.mu.Lock() 97 + defer p.mu.Unlock() 98 + 99 + runtime := instance.Runtime 100 + 101 + // Remove from busy 102 + for i, inst := range p.busy[runtime] { 103 + if inst == instance { 104 + p.busy[runtime] = append(p.busy[runtime][:i], p.busy[runtime][i+1:]...) 105 + break 106 + } 107 + } 108 + 109 + // Add back to idle 110 + p.idle[runtime] = append(p.idle[runtime], instance) 111 + } 112 + 113 + func (p *Pool) Drain() { 114 + p.mu.Lock() 115 + defer p.mu.Unlock() 116 + 117 + for runtime := range p.idle { 118 + for _, instance := range p.idle[runtime] { 119 + instance.Stop() 120 + } 121 + p.idle[runtime] = nil 122 + } 123 + 124 + for runtime := range p.busy { 125 + for _, instance := range p.busy[runtime] { 126 + instance.Stop() 127 + } 128 + p.busy[runtime] = nil 129 + } 130 + } 131 + 132 + func (p *Pool) Shutdown() { 133 + p.Drain() 134 + } 135 + 136 + func (p *Pool) Stats() PoolStats { 137 + p.mu.RLock() 138 + defer p.mu.RUnlock() 139 + 140 + stats := PoolStats{ 141 + Runtimes: make(map[string]RuntimeStats), 142 + } 143 + 144 + for runtime := range p.cfg.Runtimes { 145 + idle := len(p.idle[runtime]) 146 + busy := len(p.busy[runtime]) 147 + stats.Runtimes[runtime] = RuntimeStats{ 148 + Idle: idle, 149 + Busy: busy, 150 + } 151 + stats.Total.Idle += idle 152 + stats.Total.Busy += busy 153 + } 154 + 155 + stats.Total.Max = p.cfg.Pool.MaxVMs 156 + return stats 157 + } 158 + 159 + func (p *Pool) totalVMs() int { 160 + total := 0 161 + for runtime := range p.cfg.Runtimes { 162 + total += len(p.idle[runtime]) 163 + total += len(p.busy[runtime]) 164 + } 165 + return total 166 + } 167 + 168 + func (p *Pool) spawnInstance(runtime string) (*Instance, error) { 169 + // TODO: Actually spawn Firecracker VM 170 + // For now, return a stub instance 171 + return &Instance{ 172 + ID: fmt.Sprintf("%s-%d", runtime, p.totalVMs()), 173 + Runtime: runtime, 174 + }, nil 175 + }
+141
lima/at-rund.yaml
··· 1 + # Lima VM configuration for at-rund development 2 + # Optimized for Firecracker + Nix 3 + 4 + # Use Ubuntu 24.04 for good Firecracker support 5 + images: 6 + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img" 7 + arch: "aarch64" 8 + - location: "https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img" 9 + arch: "x86_64" 10 + 11 + # VM settings 12 + cpus: 4 13 + memory: "8GiB" 14 + disk: "50GiB" 15 + 16 + # Use Virtualization.framework on macOS (faster than QEMU) 17 + vmType: "vz" 18 + 19 + # Enable Rosetta for x86_64 binaries on Apple Silicon 20 + vmOpts: 21 + vz: 22 + rosetta: 23 + enabled: true 24 + binfmt: true 25 + 26 + # Mount the project directory 27 + mounts: 28 + - location: "~" 29 + writable: true 30 + 31 + # Provision script - install dependencies 32 + provision: 33 + - mode: system 34 + script: | 35 + #!/bin/bash 36 + set -eux -o pipefail 37 + 38 + # Update and install basics 39 + apt-get update 40 + apt-get install -y \ 41 + curl \ 42 + git \ 43 + build-essential \ 44 + pkg-config \ 45 + libssl-dev \ 46 + acl \ 47 + uidmap \ 48 + xz-utils 49 + 50 + # Install Nix (multi-user) 51 + if ! command -v nix &> /dev/null; then 52 + curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install linux --init none --no-confirm 53 + fi 54 + 55 + # Install Firecracker 56 + FIRECRACKER_VERSION="1.10.1" 57 + ARCH=$(uname -m) 58 + if [ "$ARCH" = "aarch64" ]; then 59 + FC_ARCH="aarch64" 60 + else 61 + FC_ARCH="x86_64" 62 + fi 63 + 64 + if ! command -v firecracker &> /dev/null; then 65 + curl -L "https://github.com/firecracker-microvm/firecracker/releases/download/v${FIRECRACKER_VERSION}/firecracker-v${FIRECRACKER_VERSION}-${FC_ARCH}.tgz" -o /tmp/firecracker.tgz 66 + tar -xzf /tmp/firecracker.tgz -C /tmp 67 + mv /tmp/release-v${FIRECRACKER_VERSION}-${FC_ARCH}/firecracker-v${FIRECRACKER_VERSION}-${FC_ARCH} /usr/local/bin/firecracker 68 + mv /tmp/release-v${FIRECRACKER_VERSION}-${FC_ARCH}/jailer-v${FIRECRACKER_VERSION}-${FC_ARCH} /usr/local/bin/jailer 69 + chmod +x /usr/local/bin/firecracker /usr/local/bin/jailer 70 + rm -rf /tmp/firecracker.tgz /tmp/release-v${FIRECRACKER_VERSION}-${FC_ARCH} 71 + fi 72 + 73 + # Install Go 74 + GO_VERSION="1.24.2" 75 + if ! command -v go &> /dev/null; then 76 + if [ "$ARCH" = "aarch64" ]; then 77 + GO_ARCH="arm64" 78 + else 79 + GO_ARCH="amd64" 80 + fi 81 + curl -L "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz 82 + rm -rf /usr/local/go 83 + tar -C /usr/local -xzf /tmp/go.tar.gz 84 + rm /tmp/go.tar.gz 85 + fi 86 + 87 + # Add Go to PATH for all users 88 + echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh 89 + echo 'export PATH=$PATH:$HOME/go/bin' >> /etc/profile.d/go.sh 90 + 91 + # Enable KVM access 92 + if [ -e /dev/kvm ]; then 93 + chmod 666 /dev/kvm 94 + fi 95 + 96 + - mode: user 97 + script: | 98 + #!/bin/bash 99 + set -eux -o pipefail 100 + 101 + # Source nix 102 + if [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then 103 + . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh 104 + fi 105 + 106 + # Verify installations 107 + echo "=== Installed versions ===" 108 + nix --version || echo "Nix not found" 109 + firecracker --version || echo "Firecracker not found" 110 + go version || echo "Go not found" 111 + 112 + echo "" 113 + echo "=== KVM status ===" 114 + if [ -e /dev/kvm ]; then 115 + ls -la /dev/kvm 116 + echo "KVM is available" 117 + else 118 + echo "KVM not available (expected in Lima)" 119 + fi 120 + 121 + # Forward ports 122 + portForwards: 123 + - guestPort: 3000 124 + hostPort: 3000 125 + - guestPort: 8080 126 + hostPort: 8080 127 + 128 + # Message shown after VM starts 129 + message: | 130 + at-rund development VM is ready! 131 + 132 + To enter the VM: 133 + limactl shell at-rund 134 + 135 + Your home directory is mounted at the same path. 136 + Project location: ~/code/personal/at-run/at-rund 137 + 138 + Available tools: 139 + - nix 140 + - firecracker 141 + - go
+153
nix/flake.nix
··· 1 + { 2 + description = "at-rund - AT Protocol serverless runtime daemon"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = { self, nixpkgs, flake-utils }: 10 + flake-utils.lib.eachDefaultSystem (system: 11 + let 12 + pkgs = nixpkgs.legacyPackages.${system}; 13 + 14 + # Guest agent binary (built for Linux) 15 + guestAgent = pkgs.buildGoModule { 16 + pname = "at-rund-guest"; 17 + version = "0.1.0"; 18 + src = ../guest; 19 + vendorHash = null; # Update after adding dependencies 20 + }; 21 + 22 + # Base kernel configuration for Firecracker 23 + # Firecracker requires a specific minimal kernel config 24 + firecrackerKernel = pkgs.linuxPackages_latest.kernel.override { 25 + structuredExtraConfig = with pkgs.lib.kernel; { 26 + # Firecracker requirements 27 + VIRTIO = yes; 28 + VIRTIO_PCI = yes; 29 + VIRTIO_MMIO = yes; 30 + VIRTIO_BLK = yes; 31 + VIRTIO_NET = yes; 32 + VIRTIO_CONSOLE = yes; 33 + VIRTIO_FS = yes; # For bundle mounting 34 + 35 + # vsock for host-guest communication 36 + VSOCKETS = yes; 37 + VIRTIO_VSOCKETS = yes; 38 + 39 + # Minimal init 40 + DEVTMPFS = yes; 41 + DEVTMPFS_MOUNT = yes; 42 + TMPFS = yes; 43 + PROC_FS = yes; 44 + SYSFS = yes; 45 + 46 + # Disable unnecessary features 47 + MODULES = no; 48 + SOUND = no; 49 + USB = no; 50 + WLAN = no; 51 + BLUETOOTH = no; 52 + }; 53 + }; 54 + 55 + # Base rootfs builder 56 + mkRuntimeRootfs = { name, packages, extraConfig ? {} }: pkgs.stdenv.mkDerivation { 57 + pname = "at-rund-rootfs-${name}"; 58 + version = "0.1.0"; 59 + 60 + nativeBuildInputs = with pkgs; [ 61 + e2fsprogs 62 + coreutils 63 + busybox 64 + ]; 65 + 66 + buildCommand = '' 67 + mkdir -p $out 68 + 69 + # Create rootfs structure 70 + mkdir -p rootfs/{bin,sbin,etc,dev,proc,sys,tmp,mnt/bundle,var/log} 71 + 72 + # Copy busybox for basic utilities 73 + cp ${pkgs.busybox}/bin/busybox rootfs/bin/ 74 + 75 + # Create symlinks for common commands 76 + for cmd in sh ls cat echo mkdir mount umount; do 77 + ln -s busybox rootfs/bin/$cmd 78 + done 79 + 80 + # Copy runtime packages 81 + ${pkgs.lib.concatMapStrings (pkg: '' 82 + cp -r ${pkg}/* rootfs/ || true 83 + '') packages} 84 + 85 + # Copy guest agent 86 + # cp ${guestAgent}/bin/at-rund-guest rootfs/bin/init 87 + 88 + # Create init script (placeholder until guest agent is ready) 89 + cat > rootfs/init << 'EOF' 90 + #!/bin/sh 91 + mount -t proc proc /proc 92 + mount -t sysfs sysfs /sys 93 + mount -t devtmpfs devtmpfs /dev 94 + mount -t tmpfs tmpfs /tmp 95 + 96 + echo "at-rund guest starting..." 97 + echo "Runtime: ${name}" 98 + 99 + # TODO: Start guest agent on vsock 100 + exec /bin/sh 101 + EOF 102 + chmod +x rootfs/init 103 + 104 + # Create ext4 image 105 + truncate -s 512M rootfs.ext4 106 + mkfs.ext4 -d rootfs rootfs.ext4 107 + mv rootfs.ext4 $out/ 108 + ''; 109 + }; 110 + 111 + # Deno runtime 112 + denoRuntime = mkRuntimeRootfs { 113 + name = "deno"; 114 + packages = [ pkgs.deno ]; 115 + }; 116 + 117 + # Node runtime 118 + nodeRuntime = mkRuntimeRootfs { 119 + name = "node"; 120 + packages = [ pkgs.nodejs_22 ]; 121 + }; 122 + 123 + # Python runtime 124 + pythonRuntime = mkRuntimeRootfs { 125 + name = "python"; 126 + packages = [ pkgs.python312 ]; 127 + }; 128 + 129 + in { 130 + packages = { 131 + inherit guestAgent firecrackerKernel denoRuntime nodeRuntime pythonRuntime; 132 + default = guestAgent; 133 + }; 134 + 135 + devShells.default = pkgs.mkShell { 136 + buildInputs = with pkgs; [ 137 + go 138 + gopls 139 + firecracker 140 + e2fsprogs # for mkfs.ext4 141 + ]; 142 + 143 + shellHook = '' 144 + echo "at-rund development shell" 145 + echo "Available commands:" 146 + echo " nix build .#denoRuntime - Build Deno rootfs" 147 + echo " nix build .#nodeRuntime - Build Node rootfs" 148 + echo " nix build .#firecrackerKernel - Build kernel" 149 + ''; 150 + }; 151 + } 152 + ); 153 + }
+159
nix/runtimes/deno/executor.ts
··· 1 + /** 2 + * at-run-exec for Deno runtime 3 + * 4 + * Reads execution request from stdin, runs the bundle, outputs result to stdout. 5 + * This script is invoked by at-rund via: nix shell .#deno --command at-run-exec 6 + */ 7 + 8 + interface ExecRequest { 9 + codePath: string; 10 + endpoint: string; 11 + args: unknown; 12 + permissions: { 13 + net?: string[]; 14 + read?: string[]; 15 + write?: string[]; 16 + env?: string[]; 17 + run?: string[]; 18 + ffi?: string[]; 19 + }; 20 + env: Record<string, string>; 21 + timeout: number; 22 + } 23 + 24 + interface ExecResponse { 25 + success: boolean; 26 + data?: unknown; 27 + response?: { 28 + status: number; 29 + headers: Record<string, string>; 30 + body: string; 31 + isBase64?: boolean; 32 + }; 33 + error?: string; 34 + metrics?: { 35 + executionTimeMs: number; 36 + }; 37 + } 38 + 39 + async function main() { 40 + const start = performance.now(); 41 + 42 + // Read request from stdin 43 + const input = await readStdin(); 44 + let req: ExecRequest; 45 + 46 + try { 47 + req = JSON.parse(input); 48 + } catch (e) { 49 + outputError(`Failed to parse request: ${e}`); 50 + return; 51 + } 52 + 53 + // Set environment variables 54 + for (const [key, value] of Object.entries(req.env || {})) { 55 + Deno.env.set(key, value); 56 + } 57 + 58 + try { 59 + // Import the bundle 60 + const bundleUrl = `file://${req.codePath}`; 61 + const module = await import(bundleUrl); 62 + 63 + // Find the endpoint 64 + const handler = module[req.endpoint]; 65 + if (!handler) { 66 + const available = Object.keys(module).filter(k => 67 + typeof module[k] === 'object' && module[k]?.__atrun 68 + ); 69 + outputError(`Endpoint not found: ${req.endpoint}. Available: ${available.join(', ')}`); 70 + return; 71 + } 72 + 73 + // Check if it's an at-run handler 74 + if (typeof handler !== 'object' || !handler.__atrun) { 75 + outputError(`${req.endpoint} is not an at-run endpoint`); 76 + return; 77 + } 78 + 79 + // Execute the handler 80 + const result = await handler.handler(req.args); 81 + const execTime = performance.now() - start; 82 + 83 + // Handle Response objects 84 + if (result instanceof Response) { 85 + const headers: Record<string, string> = {}; 86 + result.headers.forEach((v, k) => headers[k] = v); 87 + 88 + const contentType = result.headers.get('content-type') || ''; 89 + const isBinary = contentType.startsWith('image/') || 90 + contentType.startsWith('audio/') || 91 + contentType.startsWith('video/') || 92 + contentType === 'application/octet-stream'; 93 + 94 + let body: string; 95 + let isBase64 = false; 96 + 97 + if (isBinary) { 98 + const buffer = await result.arrayBuffer(); 99 + body = btoa(String.fromCharCode(...new Uint8Array(buffer))); 100 + isBase64 = true; 101 + } else { 102 + body = await result.text(); 103 + } 104 + 105 + output({ 106 + success: true, 107 + response: { 108 + status: result.status, 109 + headers, 110 + body, 111 + isBase64, 112 + }, 113 + metrics: { executionTimeMs: Math.round(execTime) }, 114 + }); 115 + } else { 116 + output({ 117 + success: true, 118 + data: result, 119 + metrics: { executionTimeMs: Math.round(execTime) }, 120 + }); 121 + } 122 + } catch (e) { 123 + const execTime = performance.now() - start; 124 + output({ 125 + success: false, 126 + error: e instanceof Error ? e.message : String(e), 127 + metrics: { executionTimeMs: Math.round(execTime) }, 128 + }); 129 + } 130 + } 131 + 132 + async function readStdin(): Promise<string> { 133 + const chunks: Uint8Array[] = []; 134 + for await (const chunk of Deno.stdin.readable) { 135 + chunks.push(chunk); 136 + } 137 + return new TextDecoder().decode(concat(chunks)); 138 + } 139 + 140 + function concat(chunks: Uint8Array[]): Uint8Array { 141 + const total = chunks.reduce((acc, c) => acc + c.length, 0); 142 + const result = new Uint8Array(total); 143 + let offset = 0; 144 + for (const chunk of chunks) { 145 + result.set(chunk, offset); 146 + offset += chunk.length; 147 + } 148 + return result; 149 + } 150 + 151 + function output(resp: ExecResponse) { 152 + console.log(JSON.stringify(resp)); 153 + } 154 + 155 + function outputError(error: string) { 156 + output({ success: false, error }); 157 + } 158 + 159 + main();
+61
nix/runtimes/deno/flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 1751274312, 24 + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", 25 + "owner": "NixOS", 26 + "repo": "nixpkgs", 27 + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", 28 + "type": "github" 29 + }, 30 + "original": { 31 + "owner": "NixOS", 32 + "ref": "nixos-24.11", 33 + "repo": "nixpkgs", 34 + "type": "github" 35 + } 36 + }, 37 + "root": { 38 + "inputs": { 39 + "flake-utils": "flake-utils", 40 + "nixpkgs": "nixpkgs" 41 + } 42 + }, 43 + "systems": { 44 + "locked": { 45 + "lastModified": 1681028828, 46 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 + "owner": "nix-systems", 48 + "repo": "default", 49 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 + "type": "github" 51 + }, 52 + "original": { 53 + "owner": "nix-systems", 54 + "repo": "default", 55 + "type": "github" 56 + } 57 + } 58 + }, 59 + "root": "root", 60 + "version": 7 61 + }
+93
nix/runtimes/deno/flake.nix
··· 1 + { 2 + description = "at-rund Deno runtime"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = { self, nixpkgs, flake-utils }: 10 + flake-utils.lib.eachDefaultSystem (system: 11 + let 12 + pkgs = nixpkgs.legacyPackages.${system}; 13 + 14 + # The executor script 15 + executorScript = ./executor.ts; 16 + 17 + # Wrapper that invokes the executor with proper Deno permissions 18 + at-run-exec = pkgs.writeShellScriptBin "at-run-exec" '' 19 + # Read the request from stdin to parse permissions 20 + REQUEST=$(cat) 21 + 22 + # Extract the code path (always need to read this) 23 + CODE_PATH=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.codePath') 24 + 25 + # Extract permissions from JSON request 26 + # For NET: if any entry contains *, grant full network access (Deno doesn't support wildcards) 27 + HAS_WILDCARD=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.net // [] | any(contains("*"))') 28 + if [ "$HAS_WILDCARD" = "true" ]; then 29 + NET="__ALLOW_ALL__" 30 + else 31 + NET=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.net // [] | join(",")') 32 + fi 33 + 34 + READ=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.read // [] | join(",")') 35 + WRITE=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.write // [] | join(",")') 36 + ENV=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.env // [] | join(",")') 37 + RUN=$(echo "$REQUEST" | ${pkgs.jq}/bin/jq -r '.permissions.run // [] | join(",")') 38 + 39 + # Build Deno permission flags 40 + DENO_FLAGS="--quiet --no-prompt" 41 + 42 + if [ "$NET" = "__ALLOW_ALL__" ]; then 43 + DENO_FLAGS="$DENO_FLAGS --allow-net" 44 + elif [ -n "$NET" ]; then 45 + DENO_FLAGS="$DENO_FLAGS --allow-net=$NET" 46 + fi 47 + 48 + # Always include code path in read permissions 49 + if [ -n "$READ" ]; then 50 + DENO_FLAGS="$DENO_FLAGS --allow-read=$CODE_PATH,$READ" 51 + else 52 + DENO_FLAGS="$DENO_FLAGS --allow-read=$CODE_PATH" 53 + fi 54 + 55 + if [ -n "$WRITE" ]; then 56 + DENO_FLAGS="$DENO_FLAGS --allow-write=$WRITE" 57 + fi 58 + 59 + if [ -n "$ENV" ]; then 60 + DENO_FLAGS="$DENO_FLAGS --allow-env=$ENV" 61 + fi 62 + 63 + if [ -n "$RUN" ]; then 64 + DENO_FLAGS="$DENO_FLAGS --allow-run=$RUN" 65 + fi 66 + 67 + # Execute: pipe the request back to the executor 68 + echo "$REQUEST" | ${pkgs.deno}/bin/deno run $DENO_FLAGS ${executorScript} 69 + ''; 70 + 71 + in { 72 + packages = { 73 + default = pkgs.symlinkJoin { 74 + name = "at-rund-deno-runtime"; 75 + paths = [ 76 + pkgs.deno 77 + pkgs.jq 78 + at-run-exec 79 + ]; 80 + }; 81 + }; 82 + 83 + # For development 84 + devShells.default = pkgs.mkShell { 85 + buildInputs = [ 86 + pkgs.deno 87 + pkgs.jq 88 + at-run-exec 89 + ]; 90 + }; 91 + } 92 + ); 93 + }