mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
feat(hetzner): add snapshot support + Packer image builds (#2774)
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:
parent
04eb54b409
commit
7289f3ef36
5 changed files with 309 additions and 21 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue