feat(digitalocean): Packer nightly snapshot pipeline for fast boot (#2198)

* feat(digitalocean): Packer nightly snapshot pipeline for fast boot

Add pre-built Packer snapshots for DigitalOcean droplets. Instead of
10-20 min cloud-init + agent install on every boot, snapshot-based
droplets boot in ~2-3 min (SSH only, agent pre-installed).

- Packer HCL2 template with parametrized agent/tier builds
- Agent build matrix (packer/agents.json) for all 7 agents
- Tier scripts mirroring cloud-init.ts package tiers
- Nightly GitHub Actions workflow (4 AM UTC, max-parallel: 3)
- Automatic cleanup: keeps only latest snapshot per agent
- CLI: findSpawnSnapshot() looks up pre-built images via DO API
- CLI: waitForSshOnly() skips cloud-init when using snapshots
- CLI: createServer() accepts optional snapshotId, skips user_data
- CLI: main.ts routes to fast path when snapshot detected
- Tests for findSpawnSnapshot() (5 cases, all passing)

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

* fix(packer): use var-file for install_commands to avoid shell quoting issues

The previous approach passed install_commands as `-var` inline, but
GitHub Actions expands `${{ }}` before shell evaluation — JSON arrays
with `|`, `&&`, and `"` characters break shell quoting.

Fix: generate a `.auto.pkrvars.json` file (auto-loaded by Packer)
using jq with --argjson for safe JSON handling. Also route all
`${{ inputs }}` and `${{ matrix }}` values through env vars to
prevent script injection.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-03-04 20:47:46 -08:00 committed by GitHub
parent 3242fa78f1
commit ed98a59318
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 546 additions and 10 deletions

2
packer/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.auto.pkrvars.json
.packer.d/

45
packer/agents.json Normal file
View file

@ -0,0 +1,45 @@
{
"claude": {
"tier": "minimal",
"install": [
"curl -fsSL https://claude.ai/install.sh | bash || npm install -g @anthropic-ai/claude-code"
]
},
"codex": {
"tier": "node",
"install": [
"npm install -g @openai/codex"
]
},
"openclaw": {
"tier": "full",
"install": [
"npm install -g openclaw"
]
},
"opencode": {
"tier": "minimal",
"install": [
"curl -fsSL https://opencode.ai/install | bash"
]
},
"kilocode": {
"tier": "node",
"install": [
"npm install -g @kilocode/cli"
]
},
"zeroclaw": {
"tier": "minimal",
"install": [
"fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile",
"curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/a117be64fdaa31779204beadf2942c8aef57d0e5/scripts/bootstrap.sh | bash -s -- --install-rust --install-system-deps --prefer-prebuilt"
]
},
"hermes": {
"tier": "minimal",
"install": [
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
]
}
}

127
packer/digitalocean.pkr.hcl Normal file
View file

@ -0,0 +1,127 @@
packer {
required_plugins {
digitalocean = {
version = ">= 1.4.0"
source = "github.com/digitalocean/digitalocean"
}
}
}
# Variables
variable "do_api_token" {
type = string
sensitive = true
description = "DigitalOcean API token"
}
variable "agent_name" {
type = string
description = "Agent identifier (e.g. claude, codex, openclaw)"
}
variable "cloud_init_tier" {
type = string
default = "full"
description = "Package tier: minimal, node, bun, full"
}
variable "install_commands" {
type = list(string)
default = []
description = "Shell commands to install the agent"
}
variable "region" {
type = string
default = "nyc3"
description = "Build region"
}
variable "size" {
type = string
default = "s-2vcpu-4gb"
description = "Droplet size for the build VM"
}
variable "base_image" {
type = string
default = "ubuntu-24-04-x64"
description = "Base image slug"
}
# Locals
locals {
snapshot_name = "spawn-${var.agent_name}-${formatdate("YYYYMMDD", timestamp())}"
}
# Source
source "digitalocean" "agent" {
api_token = var.do_api_token
image = var.base_image
region = var.region
size = var.size
ssh_username = "root"
snapshot_name = local.snapshot_name
snapshot_regions = [
"nyc1", "nyc3", "sfo3", "ams3", "sgp1",
"lon1", "fra1", "tor1", "blr1", "syd1",
]
tags = ["spawn", "spawn-${var.agent_name}"]
}
# Build
build {
sources = ["source.digitalocean.agent"]
# 1. System update
provisioner "shell" {
inline = [
"export DEBIAN_FRONTEND=noninteractive",
"apt-get update -y",
"apt-get upgrade -y -o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'",
]
}
# 2. Tier packages + runtimes
provisioner "shell" {
script = "scripts/tier-${var.cloud_init_tier}.sh"
}
# 3. Agent install (15 min timeout, 2 retries via wrapper)
provisioner "shell" {
inline = var.install_commands
timeout = "15m"
max_retries = 2
expect_disconnect = false
environment_vars = [
"HOME=/root",
"DEBIAN_FRONTEND=noninteractive",
]
}
# 4. Marker file + PATH setup
provisioner "shell" {
inline = [
"echo 'agent=${var.agent_name}' > /root/.spawn-snapshot",
"echo 'built=${formatdate("YYYY-MM-DD", timestamp())}' >> /root/.spawn-snapshot",
"for rc in /root/.bashrc /root/.zshrc; do grep -q '.bun/bin' \"$rc\" 2>/dev/null || echo 'export PATH=\"$HOME/.local/bin:$HOME/.bun/bin:$PATH\"' >> \"$rc\"; done",
]
}
# 5. Cleanup
provisioner "shell" {
inline = [
"apt-get clean",
"rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*",
"rm -f /var/log/cloud-init*.log /var/log/syslog /var/log/auth.log",
"truncate -s 0 /var/log/lastlog /var/log/wtmp /var/log/btmp 2>/dev/null || true",
"sync",
]
}
}

View file

@ -0,0 +1,22 @@
#!/bin/bash
set -eo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
curl \
unzip \
git \
ca-certificates \
zsh
# Bun
if ! command -v bun >/dev/null 2>&1; then
curl --proto '=https' -fsSL https://bun.sh/install | bash
fi
ln -sf /root/.bun/bin/bun /usr/local/bin/bun 2>/dev/null || true
# PATH setup
for rc in /root/.bashrc /root/.zshrc; do
grep -q '.bun/bin' "$rc" 2>/dev/null || printf 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"\n' >> "$rc"
done

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -eo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
curl \
unzip \
git \
ca-certificates \
zsh \
build-essential
# Node.js 22 via n
curl --proto '=https' -fsSL https://raw.githubusercontent.com/tj/n/master/bin/n | bash -s install 22
# Bun
if ! command -v bun >/dev/null 2>&1; then
curl --proto '=https' -fsSL https://bun.sh/install | bash
fi
ln -sf /root/.bun/bin/bun /usr/local/bin/bun 2>/dev/null || true
# PATH setup
for rc in /root/.bashrc /root/.zshrc; do
grep -q '.bun/bin' "$rc" 2>/dev/null || printf 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"\n' >> "$rc"
done

View file

@ -0,0 +1,10 @@
#!/bin/bash
set -eo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
curl \
unzip \
git \
ca-certificates

View file

@ -0,0 +1,20 @@
#!/bin/bash
set -eo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get install -y --no-install-recommends \
curl \
unzip \
git \
ca-certificates \
zsh \
build-essential
# Node.js 22 via n
curl --proto '=https' -fsSL https://raw.githubusercontent.com/tj/n/master/bin/n | bash -s install 22
# PATH setup
for rc in /root/.bashrc /root/.zshrc; do
grep -q '.bun/bin' "$rc" 2>/dev/null || printf 'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"\n' >> "$rc"
done