|
| 1 | +# Changelog |
| 2 | + |
| 3 | +## v0.2.0 — Ephemeral session helper |
| 4 | + |
| 5 | +Adds `openEphemeralSession()` — a small helper that collapses the |
| 6 | +~30 lines of resource-tracking boilerplate every lab script and |
| 7 | +bench tool was repeating into a single `await using` block. |
| 8 | + |
| 9 | +No breaking changes. The existing `Provider` interface, all four |
| 10 | +provider implementations, and the registry are unchanged. |
| 11 | + |
| 12 | +### New: `openEphemeralSession(provider, opts)` |
| 13 | + |
| 14 | +```ts |
| 15 | +import { openEphemeralSession, HetznerProvider } from 'capstan' |
| 16 | + |
| 17 | +const provider = new HetznerProvider({ token: process.env.HETZNER_API_TOKEN! }) |
| 18 | + |
| 19 | +await using session = await openEphemeralSession(provider, { |
| 20 | + name: `bench-${Date.now()}`, |
| 21 | + size: 'cx43', |
| 22 | + region: 'fsn1', |
| 23 | + publicKey: myPublicKey, |
| 24 | + userData: '#cloud-config\n...', |
| 25 | +}) |
| 26 | + |
| 27 | +const ip = await session.publicIP() |
| 28 | +// ... ssh root@ip, run work ... |
| 29 | +// On scope exit: VPS destroyed, SSH key deleted, automatically. |
| 30 | +``` |
| 31 | + |
| 32 | +What the helper handles: |
| 33 | + |
| 34 | +- Uploads the SSH key, then creates the VPS. If `createVPS()` fails, |
| 35 | + the SSH key is rolled back so failed provisioning attempts don't |
| 36 | + leak keys on the provider account. |
| 37 | +- `session.publicIP()` returns immediately if the provider gave back |
| 38 | + an IP from `createVPS()` (Hetzner / Linode / Vultr behavior). |
| 39 | + Otherwise polls `provider.getVPS()` every 2s for up to 60s |
| 40 | + (DigitalOcean behavior). Result is cached so repeated calls |
| 41 | + don't re-poll. |
| 42 | +- `session.dispose()` (and `[Symbol.asyncDispose]`) destroys the VPS |
| 43 | + first to release the public IP, then deletes the SSH key. Both |
| 44 | + steps are best-effort and never throw — a failure in one step |
| 45 | + doesn't skip the other, and a dispose during a `try/finally` |
| 46 | + doesn't mask the caller's original error. |
| 47 | +- Idempotent: `dispose()` can be called multiple times safely. |
| 48 | + |
| 49 | +### Requirements |
| 50 | + |
| 51 | +- Node 22+ (`Symbol.asyncDispose` runtime support — already required |
| 52 | + by capstan's `engines.node`) |
| 53 | +- TypeScript 5.2+ for `await using` syntax — older TS can still use |
| 54 | + the helper via explicit `await session.dispose()` in a `finally`. |
| 55 | + |
| 56 | +### Why this exists |
| 57 | + |
| 58 | +Every lab script and bench tool consuming capstan was repeating the |
| 59 | +same shape: |
| 60 | + |
| 61 | +```ts |
| 62 | +let createdKey = null |
| 63 | +let createdVPS = null |
| 64 | +const teardown = async () => { /* ~10 lines */ } |
| 65 | +process.on('SIGINT', async () => { await teardown(); process.exit(130) }) |
| 66 | +createdKey = await provider.uploadSSHKey({ ... }) |
| 67 | +createdVPS = await provider.createVPS({ ... }) |
| 68 | +let ip = createdVPS.publicIPv4 |
| 69 | +if (!ip) { /* ~10 lines of polling */ } |
| 70 | +try { /* the actual work */ } finally { await teardown() } |
| 71 | +``` |
| 72 | + |
| 73 | +That's ~30 lines of boilerplate before the script even gets to its |
| 74 | +own logic. The session helper drops it to ~5 lines and removes |
| 75 | +several common bugs (forgetting `process.on('SIGINT')`, double- |
| 76 | +destroy, key leaks on partial provisioning failure). |
| 77 | + |
| 78 | +### Tests |
| 79 | + |
| 80 | +16 new unit tests in `test/session.test.ts` against a mock provider — |
| 81 | +covers the happy path, partial-failure rollback, IP polling with |
| 82 | +configurable delay, idempotent dispose, `Symbol.asyncDispose`, and |
| 83 | +`await using` syntax end-to-end. |
| 84 | + |
| 85 | +Total test count: 123 (was 107). |
| 86 | + |
| 87 | +## v0.1.0 — Initial extraction from groundflare |
| 88 | + |
| 89 | +First public release. Multi-provider VPS lifecycle library extracted |
| 90 | +from groundflare so labs and orchestrators can consume the typed |
| 91 | +`Provider` interface without inheriting groundflare's workerd-specific |
| 92 | +weight. |
| 93 | + |
| 94 | +- Four provider implementations: Hetzner, DigitalOcean, Linode, Vultr |
| 95 | +- Typed `Provider` interface across all four |
| 96 | +- Provider registry with `createProvider(name, opts)` |
| 97 | +- Normalized `ProviderError` with HTTP status, code, and retryable flag |
| 98 | +- Monthly cost estimation per (size, region) |
| 99 | +- 107 unit tests, all passing |
| 100 | +- Published via GitHub Actions OIDC Trusted Publisher with SLSA |
| 101 | + provenance attestation |
0 commit comments