feat(hetzner): add snapshot support + Packer image builds (#2774)
Some checks failed
CLI Release / Build and release CLI (push) Failing after 31s
Lint / ShellCheck (push) Successful in 40s
Lint / Biome Lint (push) Failing after 14s
Lint / macOS Compatibility (push) Successful in 18s

CLI changes:
- Add findSpawnSnapshot() to query Hetzner /images?type=snapshot API
  for pre-built spawn-{agent}-* images (matches by description prefix)
- Add waitForSshOnly() for snapshot boots (skips cloud-init polling)
- Update createServer() to accept optional snapshotId — boots from
  snapshot instead of ubuntu-24.04, skips cloud-init userdata
- Wire up orchestrator with skipAgentInstall flag

Packer changes:
- Add packer/hetzner.pkr.hcl using hcloud plugin, mirroring the DO
  template (tier scripts, agent install, cleanup, manifest)
- Unify packer-snapshots.yml to build both DO and Hetzner in a single
  workflow with cloud×agent matrix and per-cloud cleanup steps

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Abushagur 2026-03-18 16:46:48 -07:00 committed by GitHub
parent 04eb54b409
commit 7289f3ef36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 309 additions and 21 deletions

View file

@ -470,6 +470,53 @@ export async function promptLocation(excludeLocations?: string[]): Promise<strin
return selectFromList(items, "Hetzner location", defaultLoc);
}
// ─── Snapshot Lookup ─────────────────────────────────────────────────────────
export async function findSpawnSnapshot(agentName: string): Promise<string | null> {
const r = await asyncTryCatch(async () => {
const prefix = `spawn-${agentName}-`;
const text = await hetznerApi("GET", "/images?type=snapshot&per_page=100", undefined, 1);
const data = parseJsonObj(text);
const allImages = toObjectArray(data?.images);
// Hetzner Packer sets snapshot_name → description field in the API
const images = allImages.filter((img) => isString(img.description) && img.description.startsWith(prefix));
if (images.length === 0) {
return null;
}
// Sort by created descending to get the latest snapshot
images.sort((a, b) => {
const aDate = isString(a.created) ? a.created : "";
const bDate = isString(b.created) ? b.created : "";
return bDate.localeCompare(aDate);
});
const latestId = images[0].id;
if (!isNumber(latestId) || latestId <= 0) {
return null;
}
logInfo(`Found pre-built snapshot for ${agentName} (ID: ${latestId})`);
return String(latestId);
});
return r.ok ? r.data : null;
}
// ─── SSH-Only Wait (for snapshot boots) ──────────────────────────────────────
export async function waitForSshOnly(ip?: string): Promise<void> {
const serverIp = ip || _state.serverIp;
const selectedKeys = await ensureSshKeys();
const keyOpts = getSshKeyOpts(selectedKeys);
await sharedWaitForSsh({
host: serverIp,
user: "root",
maxAttempts: 36,
extraSshOpts: keyOpts,
});
logInfo("SSH available (snapshot boot — skipping cloud-init)");
}
// ─── Provisioning ────────────────────────────────────────────────────────────
/** Check if a Hetzner API error indicates a location is unavailable (HTTP 412 resource_unavailable). */
@ -482,10 +529,12 @@ export async function createServer(
serverType?: string,
location?: string,
tier?: CloudInitTier,
snapshotId?: string,
): Promise<VMConnection> {
const sType = serverType || process.env.HETZNER_SERVER_TYPE || DEFAULT_SERVER_TYPE;
let loc = location || process.env.HETZNER_LOCATION || DEFAULT_LOCATION;
const image = "ubuntu-24.04";
const image = snapshotId ? Number(snapshotId) : "ubuntu-24.04";
const imageLabel = snapshotId ? `snapshot:${snapshotId}` : "ubuntu-24.04";
if (!validateRegionName(loc)) {
logError("Invalid HETZNER_LOCATION");
@ -495,14 +544,15 @@ export async function createServer(
// Get all SSH key IDs once (paginated to avoid missing keys beyond page 1)
const allKeys = await hetznerGetAll("/ssh_keys", "ssh_keys");
const sshKeyIds: number[] = allKeys.map((k) => (isNumber(k.id) ? k.id : 0)).filter(Boolean);
const userdata = getCloudInitUserdata(tier);
// Skip cloud-init when booting from a pre-baked snapshot
const userdata = snapshotId ? undefined : getCloudInitUserdata(tier);
// Track locations that failed so the user isn't offered them again
const failedLocations: string[] = [];
const maxLocationRetries = 3;
for (let attempt = 0; attempt <= maxLocationRetries; attempt++) {
logStep(`Creating Hetzner server '${name}' (type: ${sType}, location: ${loc})...`);
logStep(`Creating Hetzner server '${name}' (type: ${sType}, location: ${loc}, image: ${imageLabel})...`);
const body = JSON.stringify({
name,