Skip to content

Commit 41a91e7

Browse files
committed
release: v0.2.0 — ephemeral session helper
Adds openEphemeralSession() — collapses ~30 lines of resource-tracking boilerplate (upload key, create VPS, poll for IP, SIGINT handler, try/ finally cleanup) into a single `await using` block with automatic teardown. - New free function openEphemeralSession(provider, opts) - EphemeralSession with publicIP() polling + dispose() + Symbol.asyncDispose - Best-effort cleanup: never throws, idempotent - SSH key rolled back automatically if createVPS fails - 16 new unit tests against mock provider; 123 total (was 107) - No breaking changes to existing Provider interface
1 parent 88997ea commit 41a91e7

6 files changed

Lines changed: 721 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,45 @@ interface Provider {
9292

9393
See [`src/types.ts`](./src/types.ts) for the value types (`Account`, `Size`, `Region`, `SSHKey`, `VPS`, `ProvisionOptions`, `ProviderError`).
9494

95+
## Ephemeral sessions (v0.2+)
96+
97+
For lab tooling and benchmark scripts — anything that spins up a VPS, does some work, and tears down — the boilerplate is the same every time: upload SSH key, create VPS, poll for IP, remember to clean both up on every code path including SIGINT. `openEphemeralSession()` collapses that into one call with automatic cleanup via `await using`:
98+
99+
```ts
100+
import { openEphemeralSession, HetznerProvider } from 'capstan'
101+
102+
const provider = new HetznerProvider({ token: process.env.HETZNER_API_TOKEN! })
103+
104+
await using session = await openEphemeralSession(provider, {
105+
name: `bench-${Date.now()}`,
106+
size: 'cx43',
107+
region: 'fsn1',
108+
publicKey: myPublicKey, // you generate the keypair locally
109+
userData: '#cloud-config\n...', // optional cloud-init
110+
})
111+
112+
const ip = await session.publicIP() // polls if necessary
113+
// ... ssh root@ip, run your work ...
114+
115+
// On scope exit: VPS is destroyed, SSH key is deleted.
116+
// On createVPS failure: SSH key is rolled back automatically.
117+
// Both cleanup steps are best-effort and never throw.
118+
```
119+
120+
If your codebase can't use `await using` (older targets, REPL), call `await session.dispose()` from a `finally` block.
121+
122+
Requires Node 22+ and TypeScript 5.2+ — same as capstan core.
123+
95124
## Why "capstan"?
96125

97126
A capstan is the rotating drum on a ship used to hoist heavy things — anchors, sails, cables. This library hoists servers up and down. The metaphor lands.
98127

99128
## Roadmap
100129

101-
- `0.1.x` — provider abstraction (this release)
102-
- `0.2.x` — cloud-init template helpers (when needed by a downstream)
103-
- `0.3.x` — optional bootstrap-stage orchestrator (auth → ssh-key → provision → wait-ssh → cloud-init), lifted from groundflare
130+
- `0.1.x` — provider abstraction (foundation)
131+
- `0.2.x`**ephemeral session helper** (this release): `openEphemeralSession()` + `await using` cleanup
132+
- `0.3.x` — cloud-init profile registry (generic Go/Node/Python boxes vs runtime-specific YAMLs)
133+
- `0.4.x` — optional bootstrap-stage orchestrator (auth → ssh-key → provision → wait-ssh → cloud-init), lifted from groundflare
104134
- Provider additions opportunistic — Scaleway, OVH, Backblaze Compute, Fly Machines, etc.
105135

106136
## License

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "capstan",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"description": "Multi-provider VPS lifecycle library — Hetzner, DigitalOcean, Linode, Vultr behind one TypeScript interface.",
55
"keywords": [
66
"vps",

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export {
2929
type ProviderFactory,
3030
} from './registry.js'
3131

32+
export {
33+
openEphemeralSession,
34+
type EphemeralSession,
35+
type EphemeralSessionOptions,
36+
} from './session.js'
37+
3238
export {
3339
ProviderError,
3440
type Account,

0 commit comments

Comments
 (0)