@crvouga/dev-fleet 0.6.0 → 0.7.0
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 +4 -10
- package/ansible/macos/manager.yml +39 -2
- package/ansible/macos/policy.json +6 -1
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +6 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/docs/consumer-setup.md +2 -4
- package/docs/manager-recovery.md +78 -0
- package/docs/operations.md +8 -0
- package/docs/state.md +38 -6
- package/docs/topology.md +4 -5
- package/package.json +2 -2
- package/schema/check-ids.json +4 -0
- package/src/check-owners.ts +163 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-manager.ts +53 -15
- package/src/cli/fleet-sync-state.ts +18 -8
- package/src/cli/fleet-sync.ts +32 -3
- package/src/cli/register-fleet.ts +31 -0
- package/src/cli/registry.ts +14 -1
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +64 -0
- package/src/repair-plan.ts +25 -13
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
package/src/state/sync.ts
CHANGED
|
@@ -18,12 +18,29 @@ import { bucketIdentity, type Bucket } from './bucket';
|
|
|
18
18
|
import { connection } from './connection';
|
|
19
19
|
import { replicate, restore } from './litestream';
|
|
20
20
|
import { databasePath, replicatedPath, stateRoot } from './paths';
|
|
21
|
-
import {
|
|
21
|
+
import { managerHolder } from './holder';
|
|
22
|
+
import {
|
|
23
|
+
StateNewerError,
|
|
24
|
+
readHead,
|
|
25
|
+
readLease,
|
|
26
|
+
writeLease,
|
|
27
|
+
type Head,
|
|
28
|
+
type Lease,
|
|
29
|
+
} from './store';
|
|
30
|
+
|
|
31
|
+
export type { Lease } from './store';
|
|
22
32
|
|
|
23
33
|
export interface StateContext {
|
|
24
34
|
root: string;
|
|
25
35
|
bucket: Bucket | null;
|
|
26
36
|
run: Runner;
|
|
37
|
+
/** This machine, as the manager lease names it. Without one the lease is
|
|
38
|
+
* neither checked nor taken.
|
|
39
|
+
*/
|
|
40
|
+
holder?: string | null;
|
|
41
|
+
/** Take the lease from another manager before it expired. */
|
|
42
|
+
takeOver?: boolean;
|
|
43
|
+
now?: () => Date;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
export function stateContext(
|
|
@@ -34,12 +51,14 @@ export function stateContext(
|
|
|
34
51
|
root: stateRoot(env),
|
|
35
52
|
bucket: connection(env).bucket,
|
|
36
53
|
run,
|
|
54
|
+
holder: managerHolder(env),
|
|
37
55
|
};
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/** How this machine's copy compares with the bucket.
|
|
41
59
|
* - `local-only`: no bucket is configured; this copy is the only one.
|
|
42
60
|
* - `unreachable`: the bucket did not answer, or did not take an upload.
|
|
61
|
+
* - `corrupt`: the bucket answered with a version that cannot be read.
|
|
43
62
|
* - `empty`: neither side holds any state.
|
|
44
63
|
* - `unpublished`: the bucket holds nothing yet; this copy can seed it.
|
|
45
64
|
* - `in-sync`: both hold the same version.
|
|
@@ -50,6 +69,7 @@ export function stateContext(
|
|
|
50
69
|
export type Standing =
|
|
51
70
|
| 'local-only'
|
|
52
71
|
| 'unreachable'
|
|
72
|
+
| 'corrupt'
|
|
53
73
|
| 'empty'
|
|
54
74
|
| 'unpublished'
|
|
55
75
|
| 'in-sync'
|
|
@@ -109,19 +129,24 @@ const INCOMING = /^incoming-[0-9a-f-]{36}\.db/;
|
|
|
109
129
|
|
|
110
130
|
type Remote =
|
|
111
131
|
| { status: 'unreachable' }
|
|
132
|
+
| { status: 'corrupt' }
|
|
112
133
|
| { status: 'absent' }
|
|
113
134
|
| { status: 'present'; file: string; head: Head };
|
|
114
135
|
|
|
115
|
-
/** Downloads the bucket's current version beside the local one
|
|
136
|
+
/** Downloads the bucket's current version beside the local one, or the
|
|
137
|
+
* version it held at `timestamp`.
|
|
138
|
+
*/
|
|
116
139
|
async function fetchRemote(
|
|
117
140
|
context: StateContext & { bucket: Bucket },
|
|
118
|
-
install = true
|
|
141
|
+
install = true,
|
|
142
|
+
timestamp?: string
|
|
119
143
|
): Promise<Remote> {
|
|
120
144
|
const database = databasePath(context.root);
|
|
121
145
|
const file = join(context.root, `incoming-${randomUUID()}.db`);
|
|
122
146
|
const outcome = await restore(
|
|
123
147
|
{ database, bucket: context.bucket, run: context.run, install },
|
|
124
|
-
file
|
|
148
|
+
file,
|
|
149
|
+
timestamp
|
|
125
150
|
);
|
|
126
151
|
if (outcome === 'absent') return { status: 'absent' };
|
|
127
152
|
try {
|
|
@@ -130,10 +155,12 @@ async function fetchRemote(
|
|
|
130
155
|
discard(file);
|
|
131
156
|
// A replica that holds no version yet is the same as no replica.
|
|
132
157
|
return { status: outcome === 'restored' ? 'absent' : 'unreachable' };
|
|
133
|
-
} catch {
|
|
134
|
-
// A download that cannot be read says nothing about the bucket.
|
|
158
|
+
} catch (error) {
|
|
135
159
|
discard(file);
|
|
136
|
-
|
|
160
|
+
// A newer dev-fleet wrote it: refused, never taken for damage.
|
|
161
|
+
if (error instanceof StateNewerError) throw error;
|
|
162
|
+
// The bucket answered, and what it holds cannot be read.
|
|
163
|
+
return { status: 'corrupt' };
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
166
|
|
|
@@ -163,8 +190,8 @@ async function observe(
|
|
|
163
190
|
const { bucket } = context;
|
|
164
191
|
if (!bucket) return { standing: 'local-only', local, remote: null };
|
|
165
192
|
const remote = await fetchRemote({ ...context, bucket }, install);
|
|
166
|
-
if (remote.status === 'unreachable')
|
|
167
|
-
return { standing:
|
|
193
|
+
if (remote.status === 'unreachable' || remote.status === 'corrupt')
|
|
194
|
+
return { standing: remote.status, local, remote };
|
|
168
195
|
if (remote.status === 'absent')
|
|
169
196
|
return { standing: local ? 'unpublished' : 'empty', local, remote };
|
|
170
197
|
const replicated = readReplicated(context.root, bucket);
|
|
@@ -261,7 +288,9 @@ async function upload(
|
|
|
261
288
|
*/
|
|
262
289
|
function settle(context: StateContext, before: Standing): Promise<Standing> {
|
|
263
290
|
const { bucket } = context;
|
|
264
|
-
|
|
291
|
+
// A damaged replica keeps its history for a point-in-time restore.
|
|
292
|
+
if (!bucket || before === 'unreachable' || before === 'corrupt')
|
|
293
|
+
return Promise.resolve(before);
|
|
265
294
|
const head = readHead(databasePath(context.root));
|
|
266
295
|
if (
|
|
267
296
|
before === 'in-sync' &&
|
|
@@ -319,6 +348,40 @@ export function pull(context: StateContext): Promise<Standing> {
|
|
|
319
348
|
return locked(context.root, () => observed(context, true));
|
|
320
349
|
}
|
|
321
350
|
|
|
351
|
+
/** Replaces this copy with the version the bucket held at `timestamp`, then
|
|
352
|
+
* uploads it as the bucket's current version: the way back from a replica
|
|
353
|
+
* whose latest version is damaged. `unreachable` when the bucket holds no
|
|
354
|
+
* readable version at that time.
|
|
355
|
+
*/
|
|
356
|
+
export function pullAt(
|
|
357
|
+
context: StateContext,
|
|
358
|
+
timestamp: string
|
|
359
|
+
): Promise<Standing> {
|
|
360
|
+
if (Number.isNaN(Date.parse(timestamp)))
|
|
361
|
+
throw new FleetUsageError(
|
|
362
|
+
'--timestamp needs a time such as 2026-11-01T00:00:00Z.'
|
|
363
|
+
);
|
|
364
|
+
const { bucket } = context;
|
|
365
|
+
if (!bucket)
|
|
366
|
+
throw new FleetUsageError(
|
|
367
|
+
'No bucket is configured, so there is no earlier version to restore.'
|
|
368
|
+
);
|
|
369
|
+
return locked(context.root, async () => {
|
|
370
|
+
const remote = await fetchRemote(
|
|
371
|
+
{ ...context, bucket },
|
|
372
|
+
true,
|
|
373
|
+
new Date(timestamp).toISOString()
|
|
374
|
+
);
|
|
375
|
+
if (remote.status !== 'present')
|
|
376
|
+
return remote.status === 'absent' ? 'empty' : remote.status;
|
|
377
|
+
const database = databasePath(context.root);
|
|
378
|
+
discard(database);
|
|
379
|
+
renameSync(remote.file, database);
|
|
380
|
+
chmodSync(database, 0o600);
|
|
381
|
+
return upload({ ...context, bucket }, true);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
322
385
|
/** Uploads this copy's changes, only when the bucket has not moved since
|
|
323
386
|
* this copy last matched it. A copy with no changes of its own is brought up
|
|
324
387
|
* to the bucket instead; a diverged one is left alone and reported.
|
|
@@ -332,6 +395,62 @@ export function push(context: StateContext): Promise<Standing> {
|
|
|
332
395
|
});
|
|
333
396
|
}
|
|
334
397
|
|
|
398
|
+
/** How long a manager holds the lease after its last change. A machine that
|
|
399
|
+
* wakes up later than this may take it without being asked.
|
|
400
|
+
*/
|
|
401
|
+
export const LEASE_DAYS = 7;
|
|
402
|
+
const LEASE_MS = LEASE_DAYS * 24 * 60 * 60 * 1000;
|
|
403
|
+
|
|
404
|
+
/** Another manager holds the lease: this one changes nothing. */
|
|
405
|
+
export class LeaseHeldError extends FleetUsageError {}
|
|
406
|
+
|
|
407
|
+
/** The lease another manager holds over this copy, when it is not expired
|
|
408
|
+
* and this machine was not told to take it over. Reads local files only: call
|
|
409
|
+
* it on the bucket's version, after a refresh.
|
|
410
|
+
*/
|
|
411
|
+
export function leaseConflict(
|
|
412
|
+
context: StateContext,
|
|
413
|
+
now: Date = (context.now ?? (() => new Date()))()
|
|
414
|
+
): Lease | null {
|
|
415
|
+
const lease = readLease(databasePath(context.root));
|
|
416
|
+
if (!context.holder || !lease || lease.holder === context.holder) return null;
|
|
417
|
+
if (Date.parse(lease.expiresAt) <= now.getTime() || context.takeOver)
|
|
418
|
+
return null;
|
|
419
|
+
return lease;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function leaseHeldMessage(lease: Lease): string {
|
|
423
|
+
return `FLEET_MANAGER_LEASE_HELD: another manager (${lease.holder}) holds the fleet state until ${lease.expiresAt}. Nothing was changed. Run devfleet from that manager; to make this machine the manager instead, repeat with --take-over once the other one is gone (see docs/manager-recovery.md).`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Refuses when another manager holds the lease. Call it before the first
|
|
427
|
+
* write of a session.
|
|
428
|
+
*/
|
|
429
|
+
export function refuseHeldLease(context: StateContext): void {
|
|
430
|
+
const held = leaseConflict(context);
|
|
431
|
+
if (held) throw new LeaseHeldError(leaseHeldMessage(held));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Takes or renews the lease for this machine: when it is not this machine's
|
|
435
|
+
* yet, or half spent. Call it at the end of a session that may write.
|
|
436
|
+
*/
|
|
437
|
+
export function claimLease(
|
|
438
|
+
context: StateContext,
|
|
439
|
+
now: Date = (context.now ?? (() => new Date()))()
|
|
440
|
+
): void {
|
|
441
|
+
refuseHeldLease(context);
|
|
442
|
+
if (!context.holder) return;
|
|
443
|
+
const lease = readLease(databasePath(context.root));
|
|
444
|
+
const fresh =
|
|
445
|
+
lease?.holder === context.holder &&
|
|
446
|
+
Date.parse(lease.expiresAt) - now.getTime() > LEASE_MS / 2;
|
|
447
|
+
if (fresh) return;
|
|
448
|
+
writeLease(databasePath(context.root), {
|
|
449
|
+
holder: context.holder,
|
|
450
|
+
expiresAt: new Date(now.getTime() + LEASE_MS).toISOString(),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
335
454
|
export const DIVERGED_REMEDY =
|
|
336
455
|
'This machine and the bucket both changed the fleet state. The bucket is the source of truth: keep a copy of anything you still need (devfleet fleet-state --export), then run devfleet fleet-state --restore --yes.';
|
|
337
456
|
|
|
@@ -346,7 +465,9 @@ export function mutateState<T>(
|
|
|
346
465
|
return locked(context.root, async () => {
|
|
347
466
|
const before = await observed(context, false);
|
|
348
467
|
if (before === 'diverged') throw new FleetUsageError(DIVERGED_REMEDY);
|
|
468
|
+
refuseHeldLease(context);
|
|
349
469
|
const value = await change(before);
|
|
470
|
+
claimLease(context);
|
|
350
471
|
return { value, standing: await settle(context, before) };
|
|
351
472
|
});
|
|
352
473
|
}
|