@crvouga/dev-fleet 0.1.0 → 0.2.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 +237 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +7 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/monitoring/README.md +86 -25
- package/ansible/monitoring/agent.yml +40 -2
- package/ansible/monitoring/hub-route-darwin.yml +51 -0
- package/ansible/monitoring/lock.json +5 -0
- package/ansible/pyproject.toml +12 -0
- package/ansible/ubuntu/baseline.yml +1 -1
- package/ansible/ubuntu/disk-gc.py +336 -0
- package/ansible/ubuntu/policy.json +12 -3
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/uv.lock +190 -0
- package/bootstrap/bootstrap.sh +25 -18
- package/bootstrap/doctor.ts +46 -1
- package/bootstrap/fleet-manager-run.sh +42 -15
- package/bootstrap/fleet-remote-bootstrap.sh +85 -12
- package/bootstrap/global-config.ts +64 -6
- package/bootstrap/install-launcher.sh +3 -0
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +89 -0
- package/docs/machine-setup.md +399 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +67 -0
- package/docs/ubuntu-sync.md +351 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +35 -0
- package/mise.lock +29 -0
- package/mise.toml +12 -1
- package/package.json +11 -2
- package/schema/fleet.schema.json +71 -15
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/native-verify.ts +167 -22
- package/src/agents/provider-errors.ts +9 -2
- package/src/agents/registry.ts +7 -1
- package/src/cli/agent-guide.ts +39 -5
- package/src/cli/app.ts +11 -4
- package/src/cli/cli.ts +7 -0
- package/src/cli/command-index.ts +7 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-checkout.ts +56 -17
- package/src/cli/fleet-sync-enrollment.ts +19 -23
- package/src/cli/fleet-sync-forward.ts +1 -1
- package/src/cli/fleet-sync-manager.ts +64 -8
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +4 -1
- package/src/cli/fleet-sync-remote.ts +11 -5
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +123 -57
- package/src/cli/fleet.ts +4 -2
- package/src/cli/managed-health.ts +4 -4
- package/src/cli/node-sync.ts +7 -0
- package/src/cli/program.ts +20 -4
- package/src/cli/project.ts +51 -0
- package/src/cli/register-fleet.ts +51 -1
- package/src/cli/registry.ts +28 -1
- package/src/cli/roster.ts +46 -30
- package/src/cli/settings.ts +15 -12
- package/src/cli/source-operations.ts +27 -3
- package/src/cli/tailnet.ts +2 -2
- package/src/definition.ts +31 -1
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +8 -3
- package/src/monitoring/agent-evidence.ts +146 -0
- package/src/monitoring/apply.ts +15 -3
- package/src/monitoring/beszel.ts +11 -4
- package/src/monitoring/enroll.ts +2 -1
- package/src/monitoring/hub-access.ts +4 -3
- package/src/monitoring/hub-backup.ts +121 -0
- package/src/monitoring/hub-viewer.ts +8 -8
- package/src/monitoring/hub.ts +3 -3
- package/src/monitoring/manager-hub.ts +30 -14
- package/src/node-converge-linux.ts +2 -0
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/remote-bootstrap.ts +17 -19
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/state/bucket.ts +208 -0
- package/src/state/check.ts +74 -0
- package/src/state/connection.ts +92 -0
- package/src/state/enrollment.ts +45 -0
- package/src/state/legacy.ts +89 -0
- package/src/state/litestream.ts +143 -0
- package/src/state/operator.ts +20 -0
- package/src/state/paths.ts +40 -0
- package/src/state/roster.ts +62 -0
- package/src/state/store.ts +169 -0
- package/src/state/sync.ts +352 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +23 -1
- package/src/toolchain-checks.ts +25 -0
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +5 -0
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** The one directory that holds the manager's fleet state. Everything in it is
|
|
5
|
+
* a local copy: the storage bucket is the source of truth, so the directory
|
|
6
|
+
* can be deleted and restored with `fleet-state --restore`. `DEVFLEET_STATE`
|
|
7
|
+
* names another directory (a test fixture, a second fleet).
|
|
8
|
+
*/
|
|
9
|
+
export function stateRoot(env = process.env, home = homedir()): string {
|
|
10
|
+
return env['DEVFLEET_STATE'] || join(home, '.local/share/dev-fleet/state');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The fleet state database Litestream replicates. */
|
|
14
|
+
export const databasePath = (root: string): string => join(root, 'fleet.db');
|
|
15
|
+
|
|
16
|
+
/** Records the bucket's head this copy last matched (see `standing`). */
|
|
17
|
+
export const replicatedPath = (root: string): string =>
|
|
18
|
+
join(root, 'replicated.json');
|
|
19
|
+
|
|
20
|
+
/** The definition as a file, for the subprocesses that take a path. Rewritten
|
|
21
|
+
* from the database; never edited.
|
|
22
|
+
*/
|
|
23
|
+
export const definitionPath = (root: string): string =>
|
|
24
|
+
join(root, 'definition.json');
|
|
25
|
+
|
|
26
|
+
/** The bucket this manager replicates to, and its credentials. The only local
|
|
27
|
+
* file that is not a copy of something else. `DEVFLEET_BUCKET` names another.
|
|
28
|
+
*/
|
|
29
|
+
export function bucketPath(env = process.env, home = homedir()): string {
|
|
30
|
+
return env['DEVFLEET_BUCKET'] || join(home, '.config/dev-fleet/bucket.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The home directory whose earlier files (kept before the fleet state
|
|
34
|
+
* existed) may still be read, or null. A state directory named with
|
|
35
|
+
* `DEVFLEET_STATE` is another fleet's: this machine's earlier files are not
|
|
36
|
+
* part of it.
|
|
37
|
+
*/
|
|
38
|
+
export function legacyHome(env = process.env, home = homedir()): string | null {
|
|
39
|
+
return env['DEVFLEET_STATE'] ? null : home;
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { FleetUsageError } from '../errors';
|
|
6
|
+
import { adoptEarlier, readLegacy } from './legacy';
|
|
7
|
+
import { databasePath, stateRoot } from './paths';
|
|
8
|
+
import { readDocument, writeDocument } from './store';
|
|
9
|
+
|
|
10
|
+
/** The roster kept in the fleet state: what `--config` means when no file is
|
|
11
|
+
* named. A named file (`--config PATH`, `.fleet.local.json`) stays a file.
|
|
12
|
+
*/
|
|
13
|
+
export const STATE_ROSTER = 'state:roster';
|
|
14
|
+
|
|
15
|
+
/** The roster's text, or null when there is none. */
|
|
16
|
+
export async function readRosterText(
|
|
17
|
+
path: string,
|
|
18
|
+
env = process.env,
|
|
19
|
+
home = homedir()
|
|
20
|
+
): Promise<string | null> {
|
|
21
|
+
if (path === STATE_ROSTER)
|
|
22
|
+
return (
|
|
23
|
+
readDocument(databasePath(stateRoot(env, home)), 'roster') ??
|
|
24
|
+
readLegacy('roster', env, home)
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
const info = await lstat(path);
|
|
28
|
+
if (!info.isFile() || info.size > 1024 * 1024)
|
|
29
|
+
throw new FleetUsageError(
|
|
30
|
+
'Roster must be a regular file smaller than 1 MiB; symlinks are not supported.'
|
|
31
|
+
);
|
|
32
|
+
return await readFile(path, 'utf8');
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeRosterText(
|
|
40
|
+
path: string,
|
|
41
|
+
text: string,
|
|
42
|
+
env = process.env,
|
|
43
|
+
home = homedir()
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
if (path === STATE_ROSTER) {
|
|
46
|
+
adoptEarlier(env, home);
|
|
47
|
+
writeDocument(databasePath(stateRoot(env, home)), 'roster', text);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
51
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
52
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
53
|
+
try {
|
|
54
|
+
await handle.writeFile(text);
|
|
55
|
+
await handle.sync();
|
|
56
|
+
await handle.close();
|
|
57
|
+
await rename(temporary, path);
|
|
58
|
+
} finally {
|
|
59
|
+
await handle.close();
|
|
60
|
+
await unlink(temporary).catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { FleetUsageError } from '../errors';
|
|
6
|
+
|
|
7
|
+
/** Everything the manager must not lose, as one JSON text each:
|
|
8
|
+
* `definition` is the fleet configuration (fleet.json), `roster` the enrolled
|
|
9
|
+
* connection to each worker, `enrollment` the approved and applied revision.
|
|
10
|
+
*/
|
|
11
|
+
export const DOCUMENTS = ['definition', 'roster', 'enrollment'] as const;
|
|
12
|
+
export type DocumentName = (typeof DOCUMENTS)[number];
|
|
13
|
+
|
|
14
|
+
/** One version of the whole database. Every change replaces it, so two copies
|
|
15
|
+
* hold the same state exactly when their heads are equal.
|
|
16
|
+
*/
|
|
17
|
+
export interface Head {
|
|
18
|
+
id: string;
|
|
19
|
+
sequence: number;
|
|
20
|
+
writtenAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DocumentInfo {
|
|
24
|
+
name: DocumentName;
|
|
25
|
+
bytes: number;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const SCHEMA = `
|
|
32
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
33
|
+
name TEXT PRIMARY KEY,
|
|
34
|
+
body TEXT NOT NULL,
|
|
35
|
+
updated_at TEXT NOT NULL
|
|
36
|
+
) STRICT;
|
|
37
|
+
CREATE TABLE IF NOT EXISTS head (
|
|
38
|
+
one INTEGER PRIMARY KEY CHECK (one = 1),
|
|
39
|
+
id TEXT NOT NULL,
|
|
40
|
+
sequence INTEGER NOT NULL,
|
|
41
|
+
written_at TEXT NOT NULL
|
|
42
|
+
) STRICT;
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function unreadable(): FleetUsageError {
|
|
46
|
+
return new FleetUsageError(
|
|
47
|
+
'The fleet state database cannot be read. It is a local copy: run devfleet fleet-state --restore to replace it from the bucket.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reads never create the database: a machine without one has no state.
|
|
52
|
+
* The connection is still opened for writing, because a WAL database that
|
|
53
|
+
* was just downloaded has no shared-memory file yet and a read-only
|
|
54
|
+
* connection may not create one.
|
|
55
|
+
*/
|
|
56
|
+
function reading<T>(file: string, read: (db: Database) => T): T | null {
|
|
57
|
+
if (!existsSync(file)) return null;
|
|
58
|
+
let db: Database | undefined;
|
|
59
|
+
try {
|
|
60
|
+
db = new Database(file, { readwrite: true, create: false });
|
|
61
|
+
return read(db);
|
|
62
|
+
} catch {
|
|
63
|
+
throw unreadable();
|
|
64
|
+
} finally {
|
|
65
|
+
db?.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Litestream replicates the write-ahead log, so the database stays in WAL
|
|
70
|
+
* mode; a second devfleet process waits for the first instead of failing.
|
|
71
|
+
*/
|
|
72
|
+
function writing<T>(file: string, write: (db: Database) => T): T {
|
|
73
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
74
|
+
let db: Database | undefined;
|
|
75
|
+
try {
|
|
76
|
+
db = new Database(file, { create: true, readwrite: true });
|
|
77
|
+
chmodSync(file, 0o600);
|
|
78
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
|
79
|
+
db.exec(SCHEMA);
|
|
80
|
+
return db.transaction(write).immediate(db);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof FleetUsageError) throw error;
|
|
83
|
+
throw new FleetUsageError(
|
|
84
|
+
'The fleet state database cannot be written. Check the state directory permissions and free disk space.'
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
db?.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tables(db: Database): boolean {
|
|
92
|
+
return (
|
|
93
|
+
(
|
|
94
|
+
db
|
|
95
|
+
.query(
|
|
96
|
+
"SELECT count(*) AS found FROM sqlite_master WHERE type = 'table' AND name IN ('documents', 'head')"
|
|
97
|
+
)
|
|
98
|
+
.get() as { found: number }
|
|
99
|
+
).found === 2
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readDocument(file: string, name: DocumentName): string | null {
|
|
104
|
+
return reading(file, (db) => {
|
|
105
|
+
if (!tables(db)) return null;
|
|
106
|
+
const row = db
|
|
107
|
+
.query('SELECT body FROM documents WHERE name = ?1')
|
|
108
|
+
.get(name) as { body: string } | null;
|
|
109
|
+
return row?.body ?? null;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function listDocuments(file: string): DocumentInfo[] {
|
|
114
|
+
return (
|
|
115
|
+
reading(file, (db) => {
|
|
116
|
+
if (!tables(db)) return [];
|
|
117
|
+
const rows = db
|
|
118
|
+
.query(
|
|
119
|
+
'SELECT name, length(CAST(body AS BLOB)) AS bytes, updated_at AS updatedAt FROM documents ORDER BY name'
|
|
120
|
+
)
|
|
121
|
+
.all() as DocumentInfo[];
|
|
122
|
+
return rows.filter((row) => DOCUMENTS.includes(row.name));
|
|
123
|
+
}) ?? []
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function readHead(file: string): Head | null {
|
|
128
|
+
return reading(file, (db) => {
|
|
129
|
+
if (!tables(db)) return null;
|
|
130
|
+
return db
|
|
131
|
+
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
132
|
+
.get() as Head | null;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stores one document and advances the head. Writing the text already
|
|
137
|
+
* stored changes nothing, so repeating a command never looks like new state.
|
|
138
|
+
*/
|
|
139
|
+
export function writeDocument(
|
|
140
|
+
file: string,
|
|
141
|
+
name: DocumentName,
|
|
142
|
+
body: string
|
|
143
|
+
): Head {
|
|
144
|
+
if (Buffer.byteLength(body) > MAX_DOCUMENT_BYTES)
|
|
145
|
+
throw new FleetUsageError(
|
|
146
|
+
'A fleet state document must be smaller than 1 MiB.'
|
|
147
|
+
);
|
|
148
|
+
return writing(file, (db) => {
|
|
149
|
+
const current = db
|
|
150
|
+
.query('SELECT body FROM documents WHERE name = ?1')
|
|
151
|
+
.get(name) as { body: string } | null;
|
|
152
|
+
const head = db
|
|
153
|
+
.query('SELECT id, sequence, written_at AS writtenAt FROM head')
|
|
154
|
+
.get() as Head | null;
|
|
155
|
+
if (head && current?.body === body) return head;
|
|
156
|
+
const next: Head = {
|
|
157
|
+
id: randomUUID(),
|
|
158
|
+
sequence: (head?.sequence ?? 0) + 1,
|
|
159
|
+
writtenAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
db.query(
|
|
162
|
+
'INSERT INTO documents (name, body, updated_at) VALUES (?1, ?2, ?3) ON CONFLICT (name) DO UPDATE SET body = ?2, updated_at = ?3'
|
|
163
|
+
).run(name, body, next.writtenAt);
|
|
164
|
+
db.query(
|
|
165
|
+
'INSERT INTO head (one, id, sequence, written_at) VALUES (1, ?1, ?2, ?3) ON CONFLICT (one) DO UPDATE SET id = ?1, sequence = ?2, written_at = ?3'
|
|
166
|
+
).run(next.id, next.sequence, next.writtenAt);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
11
|
+
import { record } from '../config';
|
|
12
|
+
import { FleetUsageError } from '../errors';
|
|
13
|
+
import { FleetLockError } from '../machine/lock-error';
|
|
14
|
+
import { withMachineLock } from '../machine/state';
|
|
15
|
+
import { runCommand } from '../runner';
|
|
16
|
+
import type { Runner } from '../types';
|
|
17
|
+
import { bucketIdentity, type Bucket } from './bucket';
|
|
18
|
+
import { connection } from './connection';
|
|
19
|
+
import { replicate, restore } from './litestream';
|
|
20
|
+
import { databasePath, replicatedPath, stateRoot } from './paths';
|
|
21
|
+
import { readHead, type Head } from './store';
|
|
22
|
+
|
|
23
|
+
export interface StateContext {
|
|
24
|
+
root: string;
|
|
25
|
+
bucket: Bucket | null;
|
|
26
|
+
run: Runner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stateContext(
|
|
30
|
+
run: Runner = runCommand,
|
|
31
|
+
env = process.env
|
|
32
|
+
): StateContext {
|
|
33
|
+
return {
|
|
34
|
+
root: stateRoot(env),
|
|
35
|
+
bucket: connection(env).bucket,
|
|
36
|
+
run,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How this machine's copy compares with the bucket.
|
|
41
|
+
* - `local-only`: no bucket is configured; this copy is the only one.
|
|
42
|
+
* - `unreachable`: the bucket did not answer, or did not take an upload.
|
|
43
|
+
* - `empty`: neither side holds any state.
|
|
44
|
+
* - `unpublished`: the bucket holds nothing yet; this copy can seed it.
|
|
45
|
+
* - `in-sync`: both hold the same version.
|
|
46
|
+
* - `local-ahead`: this copy has changes the bucket has not received.
|
|
47
|
+
* - `bucket-ahead`: another manager changed the bucket; this copy is stale.
|
|
48
|
+
* - `diverged`: both changed. The bucket is the source of truth.
|
|
49
|
+
*/
|
|
50
|
+
export type Standing =
|
|
51
|
+
| 'local-only'
|
|
52
|
+
| 'unreachable'
|
|
53
|
+
| 'empty'
|
|
54
|
+
| 'unpublished'
|
|
55
|
+
| 'in-sync'
|
|
56
|
+
| 'local-ahead'
|
|
57
|
+
| 'bucket-ahead'
|
|
58
|
+
| 'diverged';
|
|
59
|
+
|
|
60
|
+
interface Replicated {
|
|
61
|
+
bucket: string;
|
|
62
|
+
head: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The bucket's head as of the last time this copy matched it. A copy whose
|
|
66
|
+
* head still equals it has no changes of its own and may be replaced.
|
|
67
|
+
*/
|
|
68
|
+
function readReplicated(root: string, bucket: Bucket): Replicated | null {
|
|
69
|
+
try {
|
|
70
|
+
const value: unknown = JSON.parse(
|
|
71
|
+
readFileSync(replicatedPath(root), 'utf8')
|
|
72
|
+
);
|
|
73
|
+
return record(value) &&
|
|
74
|
+
value['bucket'] === bucketIdentity(bucket) &&
|
|
75
|
+
typeof value['head'] === 'string'
|
|
76
|
+
? { bucket: value['bucket'], head: value['head'] }
|
|
77
|
+
: null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveReplicated(root: string, bucket: Bucket, head: Head): void {
|
|
84
|
+
const path = replicatedPath(root);
|
|
85
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
86
|
+
writeFileSync(
|
|
87
|
+
temporary,
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
version: 1,
|
|
90
|
+
bucket: bucketIdentity(bucket),
|
|
91
|
+
head: head.id,
|
|
92
|
+
}) + '\n',
|
|
93
|
+
{ mode: 0o600 }
|
|
94
|
+
);
|
|
95
|
+
renameSync(temporary, path);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Litestream's own record of what it has uploaded, next to the database. */
|
|
99
|
+
const bookkeeping = (database: string): string =>
|
|
100
|
+
join(dirname(database), `.${basename(database)}-litestream`);
|
|
101
|
+
|
|
102
|
+
function discard(database: string): void {
|
|
103
|
+
for (const path of [database, `${database}-wal`, `${database}-shm`])
|
|
104
|
+
rmSync(path, { force: true });
|
|
105
|
+
rmSync(bookkeeping(database), { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const INCOMING = /^incoming-[0-9a-f-]{36}\.db/;
|
|
109
|
+
|
|
110
|
+
type Remote =
|
|
111
|
+
| { status: 'unreachable' }
|
|
112
|
+
| { status: 'absent' }
|
|
113
|
+
| { status: 'present'; file: string; head: Head };
|
|
114
|
+
|
|
115
|
+
/** Downloads the bucket's current version beside the local one. */
|
|
116
|
+
async function fetchRemote(
|
|
117
|
+
context: StateContext & { bucket: Bucket },
|
|
118
|
+
install = true
|
|
119
|
+
): Promise<Remote> {
|
|
120
|
+
const database = databasePath(context.root);
|
|
121
|
+
const file = join(context.root, `incoming-${randomUUID()}.db`);
|
|
122
|
+
const outcome = await restore(
|
|
123
|
+
{ database, bucket: context.bucket, run: context.run, install },
|
|
124
|
+
file
|
|
125
|
+
);
|
|
126
|
+
if (outcome === 'absent') return { status: 'absent' };
|
|
127
|
+
try {
|
|
128
|
+
const head = outcome === 'restored' ? readHead(file) : null;
|
|
129
|
+
if (head) return { status: 'present', file, head };
|
|
130
|
+
discard(file);
|
|
131
|
+
// A replica that holds no version yet is the same as no replica.
|
|
132
|
+
return { status: outcome === 'restored' ? 'absent' : 'unreachable' };
|
|
133
|
+
} catch {
|
|
134
|
+
// A download that cannot be read says nothing about the bucket.
|
|
135
|
+
discard(file);
|
|
136
|
+
return { status: 'unreachable' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function compare(
|
|
141
|
+
local: Head | null,
|
|
142
|
+
remote: Head,
|
|
143
|
+
replicated: Replicated | null
|
|
144
|
+
): Standing {
|
|
145
|
+
if (!local) return 'bucket-ahead';
|
|
146
|
+
if (remote.id === local.id) return 'in-sync';
|
|
147
|
+
if (replicated?.head === local.id) return 'bucket-ahead';
|
|
148
|
+
if (replicated?.head === remote.id) return 'local-ahead';
|
|
149
|
+
return 'diverged';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface Observation {
|
|
153
|
+
standing: Standing;
|
|
154
|
+
local: Head | null;
|
|
155
|
+
remote: Remote | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function observe(
|
|
159
|
+
context: StateContext,
|
|
160
|
+
install = true
|
|
161
|
+
): Promise<Observation> {
|
|
162
|
+
const local = readHead(databasePath(context.root));
|
|
163
|
+
const { bucket } = context;
|
|
164
|
+
if (!bucket) return { standing: 'local-only', local, remote: null };
|
|
165
|
+
const remote = await fetchRemote({ ...context, bucket }, install);
|
|
166
|
+
if (remote.status === 'unreachable')
|
|
167
|
+
return { standing: 'unreachable', local, remote };
|
|
168
|
+
if (remote.status === 'absent')
|
|
169
|
+
return { standing: local ? 'unpublished' : 'empty', local, remote };
|
|
170
|
+
const replicated = readReplicated(context.root, bucket);
|
|
171
|
+
return { standing: compare(local, remote.head, replicated), local, remote };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One devfleet command at a time compares or changes the state. Downloads a
|
|
175
|
+
* killed command left behind are removed while nothing else can be using one.
|
|
176
|
+
*/
|
|
177
|
+
async function locked<T>(
|
|
178
|
+
root: string,
|
|
179
|
+
operation: () => Promise<T>
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
try {
|
|
182
|
+
return await withMachineLock(root, async () => {
|
|
183
|
+
for (const name of readdirSync(root))
|
|
184
|
+
if (INCOMING.test(name)) rmSync(join(root, name), { force: true });
|
|
185
|
+
return operation();
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof FleetLockError)
|
|
189
|
+
throw new FleetUsageError(
|
|
190
|
+
'Another devfleet command is using the fleet state. Wait for it to finish, then retry.'
|
|
191
|
+
);
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Replaces a stale copy with the bucket's version and records a copy that
|
|
197
|
+
* already matches. `force` replaces a copy that has changes of its own too.
|
|
198
|
+
*/
|
|
199
|
+
function adopt(
|
|
200
|
+
context: StateContext,
|
|
201
|
+
observation: Observation,
|
|
202
|
+
force: boolean
|
|
203
|
+
): Standing {
|
|
204
|
+
const { remote, local, standing } = observation;
|
|
205
|
+
if (!context.bucket || remote?.status !== 'present') return standing;
|
|
206
|
+
if (standing === 'in-sync' && local)
|
|
207
|
+
saveReplicated(context.root, context.bucket, local);
|
|
208
|
+
if (standing === 'in-sync' || (standing !== 'bucket-ahead' && !force))
|
|
209
|
+
return standing;
|
|
210
|
+
const database = databasePath(context.root);
|
|
211
|
+
discard(database);
|
|
212
|
+
renameSync(remote.file, database);
|
|
213
|
+
chmodSync(database, 0o600);
|
|
214
|
+
saveReplicated(context.root, context.bucket, remote.head);
|
|
215
|
+
return 'in-sync';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function observed(
|
|
219
|
+
context: StateContext,
|
|
220
|
+
force: boolean
|
|
221
|
+
): Promise<Standing> {
|
|
222
|
+
const observation = await observe(context);
|
|
223
|
+
try {
|
|
224
|
+
return adopt(context, observation, force);
|
|
225
|
+
} finally {
|
|
226
|
+
if (observation.remote?.status === 'present')
|
|
227
|
+
discard(observation.remote.file);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Uploads this copy, then downloads it again: the bucket holds this version
|
|
232
|
+
* only once a restore from it returns this version.
|
|
233
|
+
*/
|
|
234
|
+
async function upload(
|
|
235
|
+
context: StateContext & { bucket: Bucket },
|
|
236
|
+
seed: boolean
|
|
237
|
+
): Promise<Standing> {
|
|
238
|
+
const database = databasePath(context.root);
|
|
239
|
+
const head = readHead(database);
|
|
240
|
+
if (!head) return 'empty';
|
|
241
|
+
// A bucket with no history starts from a full copy, not from where the
|
|
242
|
+
// uploads to another bucket stopped.
|
|
243
|
+
if (seed) rmSync(bookkeeping(database), { recursive: true, force: true });
|
|
244
|
+
const uploaded = await replicate({
|
|
245
|
+
database,
|
|
246
|
+
bucket: context.bucket,
|
|
247
|
+
run: context.run,
|
|
248
|
+
});
|
|
249
|
+
const remote: Remote = uploaded
|
|
250
|
+
? await fetchRemote(context)
|
|
251
|
+
: { status: 'unreachable' };
|
|
252
|
+
if (remote.status === 'present') discard(remote.file);
|
|
253
|
+
if (remote.status !== 'present' || remote.head.id !== head.id)
|
|
254
|
+
return 'unreachable';
|
|
255
|
+
saveReplicated(context.root, context.bucket, head);
|
|
256
|
+
return 'in-sync';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** After a change made on top of `before`: uploads it when there is a bucket
|
|
260
|
+
* to upload to and this copy differs from what the bucket last matched.
|
|
261
|
+
*/
|
|
262
|
+
function settle(context: StateContext, before: Standing): Promise<Standing> {
|
|
263
|
+
const { bucket } = context;
|
|
264
|
+
if (!bucket || before === 'unreachable') return Promise.resolve(before);
|
|
265
|
+
const head = readHead(databasePath(context.root));
|
|
266
|
+
if (
|
|
267
|
+
before === 'in-sync' &&
|
|
268
|
+
readReplicated(context.root, bucket)?.head === head?.id
|
|
269
|
+
)
|
|
270
|
+
return Promise.resolve('in-sync');
|
|
271
|
+
return upload(
|
|
272
|
+
{ ...context, bucket },
|
|
273
|
+
before === 'empty' || before === 'unpublished'
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Read-only: compares this copy with the bucket and changes neither. */
|
|
278
|
+
export function standing(context: StateContext): Promise<Standing> {
|
|
279
|
+
return locked(context.root, async () => {
|
|
280
|
+
const observation = await observe(context, false);
|
|
281
|
+
if (observation.remote?.status === 'present')
|
|
282
|
+
discard(observation.remote.file);
|
|
283
|
+
return observation.standing;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface Refreshed {
|
|
288
|
+
standing: Standing;
|
|
289
|
+
/** This copy was stale and is now the bucket's version. */
|
|
290
|
+
replaced: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Brings this copy up to the bucket before it is read or changed. Never
|
|
294
|
+
* uploads, and never replaces a copy that has changes of its own.
|
|
295
|
+
*/
|
|
296
|
+
export function refresh(context: StateContext): Promise<Refreshed> {
|
|
297
|
+
return locked(context.root, async () => {
|
|
298
|
+
const database = databasePath(context.root);
|
|
299
|
+
const before = readHead(database)?.id;
|
|
300
|
+
const standing = await observed(context, false);
|
|
301
|
+
return { standing, replaced: readHead(database)?.id !== before };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** True when this copy has changes the bucket is not known to hold. Reads
|
|
306
|
+
* local files only.
|
|
307
|
+
*/
|
|
308
|
+
export function pending(context: StateContext): boolean {
|
|
309
|
+
const head = readHead(databasePath(context.root));
|
|
310
|
+
return Boolean(
|
|
311
|
+
context.bucket &&
|
|
312
|
+
head &&
|
|
313
|
+
readReplicated(context.root, context.bucket)?.head !== head.id
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Replaces this copy with the bucket's version, whatever this copy holds. */
|
|
318
|
+
export function pull(context: StateContext): Promise<Standing> {
|
|
319
|
+
return locked(context.root, () => observed(context, true));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Uploads this copy's changes, only when the bucket has not moved since
|
|
323
|
+
* this copy last matched it. A copy with no changes of its own is brought up
|
|
324
|
+
* to the bucket instead; a diverged one is left alone and reported.
|
|
325
|
+
*/
|
|
326
|
+
export function push(context: StateContext): Promise<Standing> {
|
|
327
|
+
return locked(context.root, async () => {
|
|
328
|
+
const before = await observed(context, false);
|
|
329
|
+
return ['unpublished', 'local-ahead'].includes(before)
|
|
330
|
+
? settle(context, before)
|
|
331
|
+
: before;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const DIVERGED_REMEDY =
|
|
336
|
+
'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
|
+
|
|
338
|
+
/** Makes one change on top of the bucket's current version and uploads it.
|
|
339
|
+
* A bucket that cannot be reached does not stop the change: this copy stays
|
|
340
|
+
* ahead and the next change, or `fleet-state --push`, uploads it.
|
|
341
|
+
*/
|
|
342
|
+
export function mutateState<T>(
|
|
343
|
+
context: StateContext,
|
|
344
|
+
change: (before: Standing) => T | Promise<T>
|
|
345
|
+
): Promise<{ value: T; standing: Standing }> {
|
|
346
|
+
return locked(context.root, async () => {
|
|
347
|
+
const before = await observed(context, false);
|
|
348
|
+
if (before === 'diverged') throw new FleetUsageError(DIVERGED_REMEDY);
|
|
349
|
+
const value = await change(before);
|
|
350
|
+
return { value, standing: await settle(context, before) };
|
|
351
|
+
});
|
|
352
|
+
}
|