heyquilt 0.3.90 → 0.3.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
package/src/api/relay-cluster.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The relay on many machines, from the accounts API's side (docs/superpowers/specs/2026-10-17-relay-scaling.md).
|
|
2
2
|
// Each room (session) lives on one relay machine. Machines say hello when they start and report
|
|
3
|
-
// every minute (src/relay-cluster.js)
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// every minute (src/relay-cluster.js) with their own load (memory, event-loop lag, CPU, traffic)
|
|
4
|
+
// and each room's. A room nobody placed yet goes to the least loaded machine under its load target;
|
|
5
|
+
// a heavy room gets a machine of its own. The cluster scales both ways on that load: a machine
|
|
6
|
+
// over its target for a while hands its lightest rooms to one under it (or a new one), machines
|
|
7
|
+
// that could share one consolidate, and a shared machine left empty is stopped and destroyed with
|
|
8
|
+
// its volume (relay-machines.js, Fly's Machines API), always keeping a minimum. The API reaches one
|
|
9
|
+
// machine through the relay's public address with `fly-force-instance-id`.
|
|
7
10
|
import { busyMinute, heavyReasons, HEAVY } from '../relay-metrics.js'
|
|
8
11
|
|
|
9
12
|
// A machine that has not reported for this long takes no new rooms.
|
|
@@ -14,39 +17,150 @@ export const JOB_STALE_MS = 15 * 60 * 1000
|
|
|
14
17
|
export const JOB_RETRY_MS = 10 * 60 * 1000
|
|
15
18
|
// How long a new machine has to say hello before the job gives up on it.
|
|
16
19
|
export const HELLO_WAIT_MS = 5 * 60 * 1000
|
|
20
|
+
// A machine's rooms move one at a time; its next move waits for a whole minute's report after the last.
|
|
21
|
+
export const MOVE_RETRY_MS = 90 * 1000
|
|
22
|
+
// Between marking an empty machine retiring and destroying it: a placement or move that read it as
|
|
23
|
+
// active a moment ago lands (and cancels the retire) or sees it retiring (and goes elsewhere) first.
|
|
24
|
+
export const RETIRE_GRACE_MS = 60 * 1000
|
|
25
|
+
// How often one API machine looks over the whole cluster (on a machine's report).
|
|
26
|
+
export const TICK_MS = 30 * 1000
|
|
27
|
+
const MIN = 60 * 1000
|
|
17
28
|
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
18
29
|
const MACHINE = /^[A-Za-z0-9_-]{1,64}$/
|
|
19
30
|
|
|
20
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The load a shared machine should stay under (settable on the API, relayTargetsFromEnv).
|
|
33
|
+
* Rooms placed (QUILT_RELAY_CAPACITY on the relay) are only a hard ceiling for new rooms.
|
|
34
|
+
*/
|
|
35
|
+
export const TARGETS = {
|
|
36
|
+
memory: 0.7, // memory used / the machine's memory
|
|
37
|
+
lagMs: 100, // event-loop lag (the minute's 99th percentile)
|
|
38
|
+
cpu: 0.9, // share of one core over the minute
|
|
39
|
+
hotMinutes: 3, // lag or CPU over target must last this many minutes running
|
|
40
|
+
bytesPerMin: 100 * 1024 * 1024, // WebSocket bytes in and out a minute, every room together
|
|
41
|
+
rebalanceMinutes: 5, // over target this many minutes running: its lightest rooms move off
|
|
42
|
+
emptyMinutes: 30, // a shared machine empty this long is stopped and destroyed
|
|
43
|
+
minMachines: 1, // shared machines always kept
|
|
44
|
+
consolidateBelow: 0.25, // two machines both under this share of their target: one hands its rooms to the other
|
|
45
|
+
moveGapMinutes: 30 // a room never moves again sooner than this
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ENV = {
|
|
49
|
+
memory: 'QUILT_RELAY_TARGET_MEMORY',
|
|
50
|
+
lagMs: 'QUILT_RELAY_TARGET_LAG_MS',
|
|
51
|
+
cpu: 'QUILT_RELAY_TARGET_CPU',
|
|
52
|
+
hotMinutes: 'QUILT_RELAY_TARGET_HOT_MINUTES',
|
|
53
|
+
bytesPerMin: 'QUILT_RELAY_TARGET_BYTES_PER_MIN',
|
|
54
|
+
rebalanceMinutes: 'QUILT_RELAY_REBALANCE_MINUTES',
|
|
55
|
+
emptyMinutes: 'QUILT_RELAY_EMPTY_MINUTES',
|
|
56
|
+
minMachines: 'QUILT_RELAY_MIN_MACHINES',
|
|
57
|
+
consolidateBelow: 'QUILT_RELAY_CONSOLIDATE_BELOW',
|
|
58
|
+
moveGapMinutes: 'QUILT_RELAY_MOVE_GAP_MINUTES'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** TARGETS, with any of ENV's variables set to a number (fractions above 0, counts 0 or more) in their place. */
|
|
62
|
+
export function relayTargetsFromEnv (env = process.env) {
|
|
63
|
+
const out = { ...TARGETS }
|
|
64
|
+
for (const [k, name] of Object.entries(ENV)) {
|
|
65
|
+
const raw = String(env[name] ?? '').trim()
|
|
66
|
+
if (!raw) continue
|
|
67
|
+
const v = Number(raw)
|
|
68
|
+
if (!Number.isFinite(v) || v < 0 || (v === 0 && !['minMachines', 'moveGapMinutes'].includes(k))) continue
|
|
69
|
+
out[k] = ['minMachines', 'hotMinutes', 'rebalanceMinutes'].includes(k) ? Math.round(v) : v
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Takes rooms (and is kept up): reported lately, and active or retiring (a retiring machine still serves what it holds). */
|
|
75
|
+
const up = (m, t) => (m.state === 'active' || m.state === 'retiring') && Math.max(m.reportedAt || 0, m.helloAt || 0) >= t - ALIVE_MS
|
|
76
|
+
const alive = (m, t) => m.state === 'active' && up(m, t)
|
|
77
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether something that started at `since` (a report's time: that report covered the minute before
|
|
81
|
+
* it) has lasted `minutes` by `t`. Time, not a count of reports, so a relay reporting more often
|
|
82
|
+
* than once a minute changes nothing.
|
|
83
|
+
*/
|
|
84
|
+
export const lasted = (since, minutes, t) => since != null && t - since + MIN >= minutes * MIN
|
|
85
|
+
|
|
86
|
+
/** Whether a machine's minute had its event loop or CPU over target (it must last `hotMinutes`). */
|
|
87
|
+
export function hotMinute (load = {}, targets = TARGETS) {
|
|
88
|
+
return num(load.lagMs) >= targets.lagMs || (targets.cpu > 0 && num(load.cpu) >= targets.cpu)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Each part of a machine's load as a share of its target (1 is at target); `rooms` is rooms placed against its capacity. */
|
|
92
|
+
export function loadShares (m, placed = 0, targets = TARGETS) {
|
|
93
|
+
const l = m.load || {}
|
|
94
|
+
const total = num(l.memTotal)
|
|
95
|
+
return {
|
|
96
|
+
memory: total > 0 ? num(l.memUsed) / total / targets.memory : 0,
|
|
97
|
+
lag: num(l.lagMs) / targets.lagMs,
|
|
98
|
+
cpu: targets.cpu > 0 ? num(l.cpu) / targets.cpu : 0,
|
|
99
|
+
io: num(l.bytesPerMin) / targets.bytesPerMin,
|
|
100
|
+
rooms: placed / (m.capacity || 1)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** How loaded a machine is: its most loaded part, as a share of the target. */
|
|
105
|
+
export const loadScore = (m, placed = 0, targets = TARGETS) => Math.max(...Object.values(loadShares(m, placed, targets)))
|
|
21
106
|
|
|
22
107
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* says every live shared machine is at capacity (time for another). Null when none is live.
|
|
108
|
+
* Why a machine is over its load target ('memory', 'io', 'lag'), or [] when it isn't. Lag and CPU
|
|
109
|
+
* count once they have lasted `hotMinutes` (since `hotAt`, the report that first had them over) by `t`.
|
|
26
110
|
*/
|
|
27
|
-
export function
|
|
28
|
-
const
|
|
111
|
+
export function overReasons (m, targets = TARGETS, t = Date.now()) {
|
|
112
|
+
const s = loadShares(m, 0, targets)
|
|
113
|
+
const out = []
|
|
114
|
+
if (s.memory >= 1) out.push('memory')
|
|
115
|
+
if (s.io >= 1) out.push('io')
|
|
116
|
+
if (lasted(m.hotAt, targets.hotMinutes, t)) out.push('lag')
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Where a new room goes: the active shared machine (not dedicated, not retiring) under its load
|
|
122
|
+
* target and its room ceiling with the least load, else the least loaded of them all with `full`
|
|
123
|
+
* (every one is over: time for another). Null when none is live. `exclude`: a machine to leave out.
|
|
124
|
+
*/
|
|
125
|
+
export function chooseMachine (machines, counts, t, targets = TARGETS, { exclude = null } = {}) {
|
|
126
|
+
const live = machines.filter((m) => !m.dedicatedRoom && m.id !== exclude && alive(m, t))
|
|
29
127
|
if (!live.length) return null
|
|
30
128
|
const placed = (m) => counts[m.id] || 0
|
|
31
|
-
const under = live.filter((m) => placed(m) < (m.capacity || 1))
|
|
129
|
+
const under = live.filter((m) => placed(m) < (m.capacity || 1) && !overReasons(m, targets, t).length)
|
|
32
130
|
const pool = under.length ? under : live
|
|
33
|
-
const loaded = (m) =>
|
|
34
|
-
const best = [...pool].sort((a, b) => (
|
|
131
|
+
const loaded = (m) => num(m.load && m.load.rooms)
|
|
132
|
+
const best = [...pool].sort((a, b) => loadScore(a, placed(a), targets) - loadScore(b, placed(b), targets) || loaded(a) - loaded(b) || placed(a) - placed(b) || a.id.localeCompare(b.id))[0]
|
|
35
133
|
return { machine: best, full: !under.length }
|
|
36
134
|
}
|
|
37
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The rooms of a machine that may move now, lightest first (bytes a minute, then connections,
|
|
138
|
+
* then size): placed (not mid-move), not moved in the last `moveGapMinutes`, and no git push or
|
|
139
|
+
* commit running in it (its machine's report says).
|
|
140
|
+
*/
|
|
141
|
+
export function movableRooms (rooms, t, targets = TARGETS) {
|
|
142
|
+
const s = (r) => r.stats || {}
|
|
143
|
+
return rooms
|
|
144
|
+
.filter((r) => r.state === 'placed' && !(r.movedAt && t - r.movedAt < targets.moveGapMinutes * MIN) && !(num(s(r).git) > 0))
|
|
145
|
+
.sort((a, b) => (num(s(a).bytesIn) + num(s(a).bytesOut)) - (num(s(b).bytesIn) + num(s(b).bytesOut)) || num(s(a).conns) - num(s(b).conns) || num(s(a).docBytes) - num(s(b).docBytes) || a.room.localeCompare(b.room))
|
|
146
|
+
}
|
|
147
|
+
|
|
38
148
|
/**
|
|
39
149
|
* @param {object} o
|
|
40
150
|
* @param {object} o.store the API's store (relay machine and room methods)
|
|
41
151
|
* @param {string} o.relayUrl the relay's public address (http(s)://)
|
|
42
152
|
* @param {string} o.relaySecret RELAY_API_SECRET
|
|
43
|
-
* @param {object|null} [o.fly] relay-machines.js client, or null: then heavy rooms are only logged
|
|
153
|
+
* @param {object|null} [o.fly] relay-machines.js client, or null: then heavy rooms are only logged, and no machine is added or destroyed
|
|
44
154
|
* @param {object} [o.limits] what heavy is (relay-metrics.js HEAVY)
|
|
155
|
+
* @param {object} [o.targets] the load a shared machine stays under (TARGETS)
|
|
45
156
|
*/
|
|
46
|
-
export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch = globalThis.fetch, now = Date.now, log = () => {}, fly = null, limits = HEAVY, helloWaitMs = HELLO_WAIT_MS, pollMs = 2000, callTimeoutMs = 30 * 1000, moveTimeoutMs = 30 * 60 * 1000, volumeGb = 3 } = {}) {
|
|
157
|
+
export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch = globalThis.fetch, now = Date.now, log = () => {}, fly = null, limits = HEAVY, targets = TARGETS, helloWaitMs = HELLO_WAIT_MS, pollMs = 2000, callTimeoutMs = 30 * 1000, moveTimeoutMs = 30 * 60 * 1000, volumeGb = 3, tickMs = TICK_MS, moveRetryMs = MOVE_RETRY_MS, retireGraceMs = RETIRE_GRACE_MS, maxDrainMoves = 20 } = {}) {
|
|
158
|
+
targets = { ...TARGETS, ...(targets || {}) }
|
|
47
159
|
const base = String(relayUrl).replace(/\/+$/, '').replace(/^ws(s?):\/\//, 'http$1://')
|
|
48
160
|
const jobs = new Set() // promises of jobs this API machine runs (tests wait on them)
|
|
49
161
|
const told = new Set() // heavy rooms already logged as having no Fly to move to
|
|
162
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
163
|
+
let lastTick = -Infinity
|
|
50
164
|
|
|
51
165
|
/** One relay machine: the relay's address, with Fly's proxy told which machine. */
|
|
52
166
|
async function callRelay (machineId, method, path, body, { timeoutMs = callTimeoutMs } = {}) {
|
|
@@ -59,14 +173,14 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
59
173
|
const text = await res.text()
|
|
60
174
|
let out = null
|
|
61
175
|
try { out = text ? JSON.parse(text) : null } catch { out = { text } }
|
|
62
|
-
if (!res.ok) throw new Error(`relay ${machineId || ''} ${path} answered ${res.status}${out && (out.error || out.text) ? `: ${out.error || out.text}` : ''}`)
|
|
176
|
+
if (!res.ok) throw Object.assign(new Error(`relay ${machineId || ''} ${path} answered ${res.status}${out && (out.error || out.text) ? `: ${out.error || out.text}` : ''}`), { status: res.status })
|
|
63
177
|
return out
|
|
64
178
|
}
|
|
65
179
|
|
|
66
|
-
/** Machines that are up: every one gets GitHub's nudges. */
|
|
180
|
+
/** Machines that are up (a retiring one too, while it holds rooms): every one gets GitHub's nudges. */
|
|
67
181
|
async function liveMachines () {
|
|
68
182
|
const t = now()
|
|
69
|
-
return (await store.relayMachines()).filter((m) =>
|
|
183
|
+
return (await store.relayMachines()).filter((m) => up(m, t))
|
|
70
184
|
}
|
|
71
185
|
|
|
72
186
|
/** A nudge for every machine (each tells the rooms it holds); one plain request while none has said hello. */
|
|
@@ -79,6 +193,15 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
79
193
|
return ids.length
|
|
80
194
|
}
|
|
81
195
|
|
|
196
|
+
/** A room landed on a retiring machine (on its disk, or nowhere else to go): it stays, and so does the machine. */
|
|
197
|
+
async function keepIfRetiring (machineId) {
|
|
198
|
+
const m = await store.relayMachine(machineId)
|
|
199
|
+
if (m && m.state === 'retiring') {
|
|
200
|
+
await store.putRelayMachine({ id: machineId, state: 'active', emptyAt: null })
|
|
201
|
+
log(`relay ${machineId} holds a room again: it is not retired`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
82
205
|
/** A machine started: it is live, and the rooms on its disk are its own unless placed elsewhere (those it moves aside). */
|
|
83
206
|
async function hello ({ machine, region = '', capacity, rooms = [] }) {
|
|
84
207
|
const cur = await store.relayMachine(machine)
|
|
@@ -103,32 +226,47 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
103
226
|
if (!(await store.relayMachine(machine))) await store.putRelayMachine({ id: machine, state: 'active', helloAt: now(), reportedAt: now() })
|
|
104
227
|
let target = machine
|
|
105
228
|
if (!have) {
|
|
106
|
-
const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now())
|
|
229
|
+
const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets)
|
|
107
230
|
if (pick) {
|
|
108
231
|
target = pick.machine.id
|
|
109
|
-
if (pick.full)
|
|
232
|
+
if (pick.full) scaleOnce(pick.machine)
|
|
110
233
|
}
|
|
111
234
|
}
|
|
112
235
|
const r = await store.placeRelayRoom(room, target)
|
|
236
|
+
await keepIfRetiring(r.machineId)
|
|
113
237
|
return { machine: r.machineId, state: r.state }
|
|
114
238
|
}
|
|
115
239
|
|
|
116
|
-
/** A machine's minute: its load, and each room's traffic and size; heavy rooms get a machine of their own. */
|
|
240
|
+
/** A machine's minute: its load, and each room's traffic and size; heavy rooms get a machine of their own; then the cluster is looked over. */
|
|
117
241
|
async function report ({ machine, load = {}, rooms = [] }) {
|
|
118
242
|
const t = now()
|
|
119
|
-
const
|
|
243
|
+
const prev = await store.relayMachine(machine)
|
|
244
|
+
const placedHere = (await store.relayRoomsOn(machine)).length
|
|
245
|
+
const hotAt = hotMinute(load, targets) ? (prev && prev.hotAt) || t : null
|
|
246
|
+
const over = overReasons({ load, hotAt }, targets, t)
|
|
247
|
+
const empty = !placedHere && !(num(load.rooms) > 0)
|
|
248
|
+
const m = await store.putRelayMachine({
|
|
249
|
+
id: machine,
|
|
250
|
+
load,
|
|
251
|
+
reportedAt: t,
|
|
252
|
+
// A retiring machine stays retiring while it reports; any other that reports is up.
|
|
253
|
+
state: prev && prev.state === 'retiring' ? 'retiring' : 'active',
|
|
254
|
+
hotAt,
|
|
255
|
+
overAt: over.length ? (prev && prev.overAt) || t : null,
|
|
256
|
+
emptyAt: empty ? (prev && prev.emptyAt) || t : null
|
|
257
|
+
})
|
|
120
258
|
let heavy = 0
|
|
121
259
|
for (const s of rooms) {
|
|
122
260
|
if (!s || !ROOM.test(String(s.room))) continue
|
|
123
261
|
const cur = await store.relayRoom(s.room)
|
|
124
262
|
if (!cur || cur.machineId !== machine) continue
|
|
125
|
-
const stats = { conns: s.conns | 0, msgsIn: s.msgsIn | 0, msgsOut: s.msgsOut | 0, bytesIn: +s.bytesIn || 0, bytesOut: +s.bytesOut || 0, docBytes: +s.docBytes || 0, diskBytes: +s.diskBytes || 0 }
|
|
263
|
+
const stats = { conns: s.conns | 0, msgsIn: s.msgsIn | 0, msgsOut: s.msgsOut | 0, bytesIn: +s.bytesIn || 0, bytesOut: +s.bytesOut || 0, docBytes: +s.docBytes || 0, diskBytes: +s.diskBytes || 0, git: s.git | 0 }
|
|
126
264
|
const busy = busyMinute(stats, limits) ? (cur.busyMinutes || 0) + 1 : 0
|
|
127
265
|
const why = heavyReasons(stats, limits, busy)
|
|
128
266
|
await store.setRelayRoom(s.room, { stats, busyMinutes: busy, heavy: why, reportedAt: t })
|
|
129
267
|
if (why.length && m.dedicatedRoom !== s.room && cur.state === 'placed') { heavy++; onHeavy(s.room, m, why) }
|
|
130
268
|
}
|
|
131
|
-
// A dedicated machine whose room is gone (ended, or swept) takes other rooms again.
|
|
269
|
+
// A dedicated machine whose room is gone (ended, or swept) takes other rooms again (and, left empty, is retired).
|
|
132
270
|
if (m.dedicatedRoom) {
|
|
133
271
|
const r = await store.relayRoom(m.dedicatedRoom)
|
|
134
272
|
const job = await store.relayJob(`dedicate:${m.dedicatedRoom}`)
|
|
@@ -137,7 +275,9 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
137
275
|
log(`relay ${machine}: ${m.dedicatedRoom} is not on it any more; it takes other rooms now`)
|
|
138
276
|
}
|
|
139
277
|
}
|
|
140
|
-
|
|
278
|
+
if (over.length && !(prev && prev.overAt)) log(`relay ${machine} is over its load target (${over.join(', ')})`)
|
|
279
|
+
tick()
|
|
280
|
+
return { ok: true, heavy, over }
|
|
141
281
|
}
|
|
142
282
|
|
|
143
283
|
async function forget ({ room, machine }) {
|
|
@@ -145,10 +285,10 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
145
285
|
return { ok: true }
|
|
146
286
|
}
|
|
147
287
|
|
|
148
|
-
/** Runs `fn` as the job `key` unless it is running (here or on another API machine) or ran
|
|
149
|
-
function startJob (key, detail, fn) {
|
|
150
|
-
|
|
151
|
-
if (!(await store.takeRelayJob(key, { detail, staleMs: JOB_STALE_MS, retryMs
|
|
288
|
+
/** Runs `fn` as the job `key` unless it is running (here or on another API machine) or ran within `retryMs`. */
|
|
289
|
+
function startJob (key, detail, fn, { retryMs = JOB_RETRY_MS } = {}) {
|
|
290
|
+
return track((async () => {
|
|
291
|
+
if (!(await store.takeRelayJob(key, { detail, staleMs: JOB_STALE_MS, retryMs }))) return null
|
|
152
292
|
try {
|
|
153
293
|
const out = await fn()
|
|
154
294
|
await store.finishRelayJob(key, { state: 'done', detail: { ...detail, ...(out || {}) } })
|
|
@@ -158,7 +298,10 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
158
298
|
await store.finishRelayJob(key, { state: 'failed', detail: { ...detail, error: String(err.message).slice(0, 300) } }).catch(() => {})
|
|
159
299
|
return null
|
|
160
300
|
}
|
|
161
|
-
})().catch((err) => { log(`relay job ${key}: ${err.message}`); return null })
|
|
301
|
+
})().catch((err) => { log(`relay job ${key}: ${err.message}`); return null }))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function track (p) {
|
|
162
305
|
jobs.add(p)
|
|
163
306
|
p.finally(() => jobs.delete(p))
|
|
164
307
|
return p
|
|
@@ -177,32 +320,42 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
177
320
|
const others = (await store.relayRoomsOn(from.id)).filter((r) => r.room !== room)
|
|
178
321
|
if (!others.length) {
|
|
179
322
|
// Alone on its machine already: that machine is now its own, and takes no others.
|
|
180
|
-
await store.putRelayMachine({ id: from.id, dedicatedRoom: room })
|
|
323
|
+
await store.putRelayMachine({ id: from.id, dedicatedRoom: room, state: 'active', emptyAt: null })
|
|
181
324
|
log(`room ${room} keeps relay ${from.id} to itself`)
|
|
182
325
|
return { machine: from.id, moved: false }
|
|
183
326
|
}
|
|
184
327
|
// A machine already up with no rooms (one an earlier, failed move made, or a spare shared one)
|
|
185
328
|
// goes first: a retry never leaves the last try's machine running empty.
|
|
186
329
|
const spare = await spareMachine(room, from.id)
|
|
187
|
-
if (spare) await store.putRelayMachine({ id: spare, dedicatedRoom: room })
|
|
330
|
+
if (spare) await store.putRelayMachine({ id: spare, dedicatedRoom: room, emptyAt: null })
|
|
188
331
|
const made = spare || await newMachine({ region: from.region, dedicatedRoom: room, template: from.id })
|
|
189
|
-
await moveRoom(room, from.id, made)
|
|
332
|
+
const r = await moveRoom(room, from.id, made)
|
|
333
|
+
if (!r.moved) throw new Error(`room ${room} could not move now (${r.why})`)
|
|
190
334
|
return { machine: made, moved: true }
|
|
191
335
|
}
|
|
192
336
|
|
|
193
|
-
/**
|
|
337
|
+
/** An active machine other than `fromId` holding no rooms, shared or kept for `room`; null when none. */
|
|
194
338
|
async function spareMachine (room, fromId) {
|
|
195
|
-
|
|
196
|
-
|
|
339
|
+
const t = now()
|
|
340
|
+
for (const m of await store.relayMachines()) {
|
|
341
|
+
if (!alive(m, t) || m.id === fromId || (m.dedicatedRoom && m.dedicatedRoom !== room)) continue
|
|
197
342
|
if (!(await store.relayRoomsOn(m.id)).length) return m.id
|
|
198
343
|
}
|
|
199
344
|
return null
|
|
200
345
|
}
|
|
201
346
|
|
|
202
|
-
/** Every shared machine is
|
|
347
|
+
/** Every shared machine is over its target: one more shared machine (one at a time, the 'scale' job). */
|
|
348
|
+
function scaleOnce (like) {
|
|
349
|
+
if (!fly) {
|
|
350
|
+
if (!told.has(':scale')) { told.add(':scale'); log('every shared relay machine is over its load target; no Fly token, so no machine is added') }
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
353
|
+
return startJob('scale', { region: like.region }, () => scaleUp(like))
|
|
354
|
+
}
|
|
355
|
+
|
|
203
356
|
async function scaleUp (like) {
|
|
204
357
|
const id = await newMachine({ region: like.region, dedicatedRoom: null, template: like.id })
|
|
205
|
-
log(`added relay ${id}: every shared machine was
|
|
358
|
+
log(`added relay ${id}: every shared machine was over its load target`)
|
|
206
359
|
return { machine: id }
|
|
207
360
|
}
|
|
208
361
|
|
|
@@ -215,22 +368,33 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
215
368
|
const row = await store.relayMachine(m.id)
|
|
216
369
|
if (row && row.helloAt) return m.id
|
|
217
370
|
if (now() >= until) throw new Error(`relay machine ${m.id} did not start in ${Math.round(helloWaitMs / 1000)} s`)
|
|
218
|
-
await
|
|
371
|
+
await sleep(pollMs)
|
|
219
372
|
}
|
|
220
373
|
}
|
|
221
374
|
|
|
222
375
|
/**
|
|
223
376
|
* Moves `room` from machine `from` to `to`: the source lets it go (its people reconnect), the
|
|
224
377
|
* target copies its files from the source, placement flips, the source forgets its copy. Until
|
|
225
|
-
* the flip, a failure puts the room back where it was.
|
|
378
|
+
* the flip, a failure puts the room back where it was. { moved: false, why } when the source
|
|
379
|
+
* would not let it go now (a git push or commit running in it) or the target stopped taking rooms.
|
|
226
380
|
*/
|
|
227
381
|
async function moveRoom (room, from, to) {
|
|
228
382
|
await store.setRelayRoom(room, { state: 'moving' })
|
|
229
|
-
let flipped = false
|
|
230
383
|
try {
|
|
231
384
|
await callRelay(from, 'POST', `/cluster/rooms/${room}/release`, {})
|
|
385
|
+
} catch (err) {
|
|
386
|
+
await store.setRelayRoom(room, { machineId: from, state: 'placed' }).catch(() => {})
|
|
387
|
+
if (err.status === 409) { log(`room ${room} stays on relay ${from} for now: ${err.message}`); return { moved: false, why: 'busy' } }
|
|
388
|
+
await callRelay(from, 'POST', `/cluster/rooms/${room}/restore`, {}).catch((e) => log(`could not restore ${room} on ${from}: ${e.message}`))
|
|
389
|
+
throw err
|
|
390
|
+
}
|
|
391
|
+
let flipped = false
|
|
392
|
+
try {
|
|
232
393
|
const got = await callRelay(to, 'POST', `/cluster/rooms/${room}/fetch`, { from }, { timeoutMs: moveTimeoutMs })
|
|
233
|
-
|
|
394
|
+
// The target may have started retiring while the copy ran: then the room stays where it was.
|
|
395
|
+
const target = await store.relayMachine(to)
|
|
396
|
+
if (!target || target.state !== 'active') throw Object.assign(new Error(`relay ${to} is ${target ? target.state : 'gone'}`), { notTaking: true })
|
|
397
|
+
await store.setRelayRoom(room, { machineId: to, state: 'placed', busyMinutes: 0, movedAt: now() })
|
|
234
398
|
flipped = true
|
|
235
399
|
log(`moved room ${room} from relay ${from} to ${to} (${got && got.files} files, ${got && got.bytes} bytes)`)
|
|
236
400
|
} catch (err) {
|
|
@@ -239,13 +403,143 @@ export function relayCluster ({ store, relayUrl = '', relaySecret = '', fetch =
|
|
|
239
403
|
await callRelay(from, 'POST', `/cluster/rooms/${room}/restore`, {}).catch((e) => log(`could not restore ${room} on ${from}: ${e.message}`))
|
|
240
404
|
await callRelay(to, 'POST', `/cluster/rooms/${room}/forget`, { to: from }).catch(() => {})
|
|
241
405
|
}
|
|
406
|
+
if (err.notTaking) { log(`room ${room} stays on relay ${from}: ${err.message}`); return { moved: false, why: 'target' } }
|
|
242
407
|
throw err
|
|
243
408
|
}
|
|
244
409
|
await callRelay(from, 'POST', `/cluster/rooms/${room}/forget`, { to }).catch((err) => log(`relay ${from} could not delete its copy of ${room}: ${err.message}`))
|
|
410
|
+
return { moved: true }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ---- scaling both ways, on load ----
|
|
414
|
+
|
|
415
|
+
/** At most every `tickMs` (per API machine), on a machine's report: the whole cluster is looked over. */
|
|
416
|
+
function tick () {
|
|
417
|
+
const t = now()
|
|
418
|
+
if (t - lastTick < tickMs) return null
|
|
419
|
+
lastTick = t
|
|
420
|
+
return track(autoscale().catch((err) => { log(`relay autoscale: ${err.message}`); return null }))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function autoscale () {
|
|
424
|
+
const t = now()
|
|
425
|
+
const machines = await store.relayMachines()
|
|
426
|
+
const counts = await store.relayRoomCounts()
|
|
427
|
+
const placed = (m) => counts[m.id] || 0
|
|
428
|
+
const shared = machines.filter((m) => !m.dedicatedRoom && up(m, t))
|
|
429
|
+
const active = shared.filter((m) => m.state === 'active')
|
|
430
|
+
// Shared machines staying: retiring one needs this to stay at least the minimum.
|
|
431
|
+
let keep = active.length
|
|
432
|
+
|
|
433
|
+
// A retiring machine still holding rooms hands them on, one at a time.
|
|
434
|
+
for (const m of shared) if (m.state === 'retiring' && placed(m)) startJob(`move:${m.id}`, { machine: m.id, why: 'retiring' }, () => drain(m.id), { retryMs: moveRetryMs })
|
|
435
|
+
|
|
436
|
+
// Empty long enough: stopped and destroyed with its volume, keeping the minimum.
|
|
437
|
+
for (const m of shared) {
|
|
438
|
+
if (placed(m) || num(m.load && m.load.rooms) > 0 || !m.emptyAt || t - m.emptyAt < targets.emptyMinutes * MIN) continue
|
|
439
|
+
if (!fly) {
|
|
440
|
+
if (!told.has(`:empty:${m.id}`)) { told.add(`:empty:${m.id}`); log(`relay ${m.id} has been empty for ${targets.emptyMinutes} minutes; no Fly token, so it keeps running`) }
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
if (m.state === 'active') {
|
|
444
|
+
if (keep - 1 < targets.minMachines) continue
|
|
445
|
+
keep--
|
|
446
|
+
} else if (keep < targets.minMachines) {
|
|
447
|
+
await store.putRelayMachine({ id: m.id, state: 'active' })
|
|
448
|
+
keep++
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
startJob(`retire:${m.id}`, { machine: m.id }, () => retire(m.id))
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Over target for a while, with rooms to spare: its lightest rooms move off, one at a time.
|
|
455
|
+
for (const m of active) {
|
|
456
|
+
if (placed(m) < 2 || !lasted(m.overAt, targets.rebalanceMinutes, t)) continue
|
|
457
|
+
startJob(`move:${m.id}`, { machine: m.id, why: 'over target' }, () => rebalance(m.id), { retryMs: moveRetryMs })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Two (or more) lightly loaded machines that have been up a while: the emptier hands its rooms
|
|
461
|
+
// to the others and, once empty, is retired. One at a time, never below the minimum.
|
|
462
|
+
if (fly && !shared.some((m) => m.state === 'retiring') && active.length > Math.max(1, targets.minMachines)) {
|
|
463
|
+
const light = active.filter((m) => placed(m) > 0 && !m.overAt && (m.helloAt || 0) <= t - targets.emptyMinutes * MIN && loadScore(m, placed(m), targets) < targets.consolidateBelow)
|
|
464
|
+
if (light.length >= 2) {
|
|
465
|
+
const [from] = light.sort((a, b) => placed(a) - placed(b) || loadScore(a, placed(a), targets) - loadScore(b, placed(b), targets) || (b.createdAt || 0) - (a.createdAt || 0) || b.id.localeCompare(a.id))
|
|
466
|
+
await store.putRelayMachine({ id: from.id, state: 'retiring' })
|
|
467
|
+
log(`relay ${from.id} is lightly loaded, and so is another: its ${placed(from)} room(s) move off and it is retired`)
|
|
468
|
+
startJob(`move:${from.id}`, { machine: from.id, why: 'consolidating' }, () => drain(from.id), { retryMs: moveRetryMs })
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A machine over its target: its lightest movable room goes to the least loaded machine under target, else a new machine is asked for. */
|
|
474
|
+
async function rebalance (id) {
|
|
475
|
+
const m = await store.relayMachine(id)
|
|
476
|
+
if (!m || m.state !== 'active' || m.dedicatedRoom || !lasted(m.overAt, targets.rebalanceMinutes, now())) return { skipped: 'not over target' }
|
|
477
|
+
const all = await store.relayRoomsOn(id)
|
|
478
|
+
const rooms = movableRooms(all, now(), targets).filter((r) => !(r.heavy && r.heavy.length))
|
|
479
|
+
if (all.length < 2 || !rooms.length) return { skipped: 'no room may move now' }
|
|
480
|
+
const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets, { exclude: id })
|
|
481
|
+
if (!pick || pick.full) {
|
|
482
|
+
scaleOnce(m)
|
|
483
|
+
return { waiting: 'a machine under target' }
|
|
484
|
+
}
|
|
485
|
+
for (const r of rooms) {
|
|
486
|
+
const out = await moveRoom(r.room, id, pick.machine.id)
|
|
487
|
+
if (out.moved) return { room: r.room, to: pick.machine.id }
|
|
488
|
+
if (out.why === 'target') break
|
|
489
|
+
}
|
|
490
|
+
return { skipped: 'no room could move now' }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A retiring machine's rooms go to the least loaded machines under target; with none, it is not retired after all. */
|
|
494
|
+
async function drain (id) {
|
|
495
|
+
const moved = []
|
|
496
|
+
const skip = new Set()
|
|
497
|
+
for (let i = 0; i < maxDrainMoves; i++) {
|
|
498
|
+
const m = await store.relayMachine(id)
|
|
499
|
+
if (!m || m.state !== 'retiring') break
|
|
500
|
+
const rooms = movableRooms(await store.relayRoomsOn(id), now(), targets).filter((r) => !skip.has(r.room))
|
|
501
|
+
if (!rooms.length) break
|
|
502
|
+
const pick = chooseMachine(await store.relayMachines(), await store.relayRoomCounts(), now(), targets, { exclude: id })
|
|
503
|
+
if (!pick || pick.full) {
|
|
504
|
+
await store.putRelayMachine({ id, state: 'active' })
|
|
505
|
+
log(`relay ${id} keeps its rooms: no other machine is under its load target`)
|
|
506
|
+
return { moved, cancelled: true }
|
|
507
|
+
}
|
|
508
|
+
const out = await moveRoom(rooms[0].room, id, pick.machine.id)
|
|
509
|
+
if (out.moved) moved.push(rooms[0].room); else skip.add(rooms[0].room)
|
|
510
|
+
}
|
|
511
|
+
return { moved }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** An empty shared machine: retiring (no new rooms), then stopped and destroyed with its volume, unless a room lands on it first. */
|
|
515
|
+
async function retire (id) {
|
|
516
|
+
const m = await store.relayMachine(id)
|
|
517
|
+
if (!m || m.dedicatedRoom || !up(m, now())) return { skipped: 'not a live shared machine' }
|
|
518
|
+
if ((await store.relayRoomsOn(id)).length) return { skipped: 'holds rooms' }
|
|
519
|
+
const others = (await store.relayMachines()).filter((x) => x.id !== id && !x.dedicatedRoom && alive(x, now()))
|
|
520
|
+
if (others.length < targets.minMachines) {
|
|
521
|
+
if (m.state === 'retiring') await store.putRelayMachine({ id, state: 'active' })
|
|
522
|
+
return { skipped: 'the last machines are kept' }
|
|
523
|
+
}
|
|
524
|
+
if (m.state !== 'retiring') await store.putRelayMachine({ id, state: 'retiring' })
|
|
525
|
+
if (retireGraceMs > 0) await sleep(retireGraceMs)
|
|
526
|
+
const cur = await store.relayMachine(id)
|
|
527
|
+
if (!cur || cur.state !== 'retiring' || (await store.relayRoomsOn(id)).length) {
|
|
528
|
+
if (cur && cur.state === 'retiring') await store.putRelayMachine({ id, state: 'active', emptyAt: null })
|
|
529
|
+
log(`relay ${id} is not retired: a room landed on it`)
|
|
530
|
+
return { cancelled: true }
|
|
531
|
+
}
|
|
532
|
+
const out = await fly.destroyRelayMachine(id)
|
|
533
|
+
await store.putRelayMachine({ id, state: 'gone', emptyAt: null, hotAt: null, overAt: null })
|
|
534
|
+
// A room placed on it in the last moment had nothing there yet: it is placed afresh when next asked for.
|
|
535
|
+
for (const r of await store.relayRoomsOn(id)) { await store.forgetRelayRoom(r.room, id); log(`room ${r.room} was placed on relay ${id} as it was destroyed; it is placed again when next asked for`) }
|
|
536
|
+
log(`retired relay ${id}: empty for ${targets.emptyMinutes} minutes (destroyed with ${(out && out.volumes && out.volumes.length) || 0} volume(s))`)
|
|
537
|
+
for (const v of (out && out.leftover) || []) log(`relay ${id} is destroyed, but Fly would not delete its volume ${v.volume} (${v.error}): delete it by hand`)
|
|
538
|
+
return { destroyed: true, volumes: (out && out.volumes) || [], ...(out && out.leftover && out.leftover.length ? { leftover: out.leftover.map((v) => v.volume) } : {}) }
|
|
245
539
|
}
|
|
246
540
|
|
|
247
541
|
/** Waits for every job started so far (tests). */
|
|
248
542
|
async function idle () { while (jobs.size) await Promise.allSettled([...jobs]) }
|
|
249
543
|
|
|
250
|
-
return { hello, place, report, forget, tellRelays, callRelay, moveRoom, dedicate, idle, valid: { room: (r) => ROOM.test(String(r)), machine: (m) => MACHINE.test(String(m)) } }
|
|
544
|
+
return { hello, place, report, forget, tellRelays, callRelay, moveRoom, dedicate, autoscale, rebalance, drain, retire, idle, targets, valid: { room: (r) => ROOM.test(String(r)), machine: (m) => MACHINE.test(String(m)) } }
|
|
251
545
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Fly's Machines API, for the relay's machines (src/api/relay-cluster.js): a new relay machine is
|
|
2
2
|
// a copy of a running one's config (image, size, services, checks, env), with a volume of its own
|
|
3
3
|
// at the same path. `fly deploy` updates it with the others: it keeps the app's process group.
|
|
4
|
+
// A machine the cluster no longer needs (empty a while) is stopped and destroyed with its volume.
|
|
4
5
|
// Docs: https://fly.io/docs/machines/api/
|
|
5
6
|
export const FLY_API = 'https://api.machines.dev/v1'
|
|
6
7
|
|
|
@@ -14,7 +15,7 @@ export function relayFlyFromEnv (env = process.env, o = {}) {
|
|
|
14
15
|
return relayFlyMachines({ token, app: env.QUILT_RELAY_APP || 'cowove-relay', ...o })
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base = FLY_API, timeoutMs = 60 * 1000 }) {
|
|
18
|
+
export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base = FLY_API, timeoutMs = 60 * 1000, volumeWaitMs = 2 * 60 * 1000, pollMs = 3000 }) {
|
|
18
19
|
const root = String(base).replace(/\/+$/, '')
|
|
19
20
|
const call = async (method, path, body) => {
|
|
20
21
|
const res = await fetch(`${root}${path}`, {
|
|
@@ -26,7 +27,7 @@ export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base =
|
|
|
26
27
|
const text = await res.text()
|
|
27
28
|
let out = null
|
|
28
29
|
try { out = text ? JSON.parse(text) : null } catch { out = { error: text } }
|
|
29
|
-
if (!res.ok) throw new Error(`Fly answered ${res.status} for ${method} ${path.split('?')[0]}${out && out.error ? `: ${String(out.error).slice(0, 200)}` : ''}`)
|
|
30
|
+
if (!res.ok) throw Object.assign(new Error(`Fly answered ${res.status} for ${method} ${path.split('?')[0]}${out && out.error ? `: ${String(out.error).slice(0, 200)}` : ''}`), { status: res.status })
|
|
30
31
|
return out
|
|
31
32
|
}
|
|
32
33
|
const appPath = `/apps/${encodeURIComponent(app)}`
|
|
@@ -56,5 +57,36 @@ export function relayFlyMachines ({ token, app, fetch = globalThis.fetch, base =
|
|
|
56
57
|
return { id: m.id, region: m.region || where, volume: vol.id }
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Stops relay machine `id`, destroys it, then deletes its volumes (Fly frees a volume a moment after
|
|
62
|
+
* its machine goes, so that is tried until `volumeWaitMs`). A machine Fly no longer has is done
|
|
63
|
+
* already. Resolves { id, volumes, leftover }: the volumes deleted, and any Fly would not delete
|
|
64
|
+
* in time (the machine is gone either way; those are for someone to delete by hand).
|
|
65
|
+
*/
|
|
66
|
+
async function destroyRelayMachine (id) {
|
|
67
|
+
const mp = `${appPath}/machines/${encodeURIComponent(id)}`
|
|
68
|
+
let m = null
|
|
69
|
+
try { m = await call('GET', mp) } catch (err) { if (err.status !== 404) throw err }
|
|
70
|
+
const volumes = ((m && m.config && Array.isArray(m.config.mounts) && m.config.mounts) || []).map((x) => x && x.volume).filter(Boolean)
|
|
71
|
+
if (m) {
|
|
72
|
+
await call('POST', `${mp}/stop`, {}).catch(() => {})
|
|
73
|
+
await call('GET', `${mp}/wait?state=stopped&timeout=60`).catch(() => {})
|
|
74
|
+
try { await call('DELETE', `${mp}?force=true`) } catch (err) { if (err.status !== 404) throw err }
|
|
75
|
+
}
|
|
76
|
+
const until = Date.now() + volumeWaitMs
|
|
77
|
+
const deleted = []
|
|
78
|
+
const leftover = []
|
|
79
|
+
for (const v of volumes) {
|
|
80
|
+
for (;;) {
|
|
81
|
+
try { await call('DELETE', `${appPath}/volumes/${encodeURIComponent(v)}`); deleted.push(v); break } catch (err) {
|
|
82
|
+
if (err.status === 404) { deleted.push(v); break }
|
|
83
|
+
if (Date.now() >= until) { leftover.push({ volume: v, error: err.message }); break }
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { id, volumes: deleted, leftover }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { createRelayMachine, destroyRelayMachine, app }
|
|
60
92
|
}
|