@crvouga/dev-fleet 0.1.0 → 0.3.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 +265 -37
- package/agents/setup.md +42 -12
- package/ansible/macos/manager.yml +10 -7
- package/ansible/macos/policy.json +2 -10
- package/ansible/macos/t3-policy.yml +49 -0
- 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 +13 -3
- package/ansible/ubuntu/t3-desktop.yml +19 -0
- package/ansible/ubuntu/t3-health.sh +93 -22
- package/ansible/ubuntu/t3-idle.py +42 -0
- package/ansible/ubuntu/t3-policy.yml +4 -11
- package/ansible/ubuntu/tailscale.yml +8 -5
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +61 -1
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- 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/native-t3.ts +22 -11
- package/bootstrap/npm.ts +10 -2
- package/bootstrap/release-version.ts +157 -0
- package/cliproxy/README.md +3 -3
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +29 -1
- package/cliproxy/scripts/cli.py +1 -1
- package/cliproxy/scripts/clientkeys.py +99 -0
- package/cliproxy/scripts/lease.py +80 -0
- package/cliproxy/scripts/ops +137 -25
- package/cliproxy/scripts/restore-verify +15 -0
- package/docs/accounts.md +154 -0
- package/docs/consumer-setup.md +408 -0
- package/docs/declared.md +91 -0
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +400 -0
- package/docs/operations.md +276 -0
- package/docs/releasing.md +103 -0
- package/docs/state.md +254 -0
- package/docs/topology.md +69 -0
- package/docs/ubuntu-sync.md +357 -0
- package/docs/worker-contract.md +67 -0
- package/examples/fleet.json +5 -0
- package/llms.txt +36 -0
- package/mise.lock +29 -0
- package/mise.toml +15 -2
- package/package.json +11 -2
- package/schema/fleet.schema.json +137 -15
- package/src/accounts/checks.ts +220 -0
- package/src/accounts/context.ts +76 -0
- package/src/accounts/propagate.ts +62 -0
- package/src/accounts/roster.ts +85 -0
- package/src/accounts/selection.ts +67 -0
- package/src/accounts/sessions.ts +120 -0
- package/src/agents/account-relay.ts +176 -26
- package/src/agents/fleet-verify.ts +9 -0
- package/src/agents/launch.ts +84 -5
- package/src/agents/native-verify.ts +293 -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-accounts.ts +253 -0
- package/src/cli/fleet-state.ts +499 -0
- package/src/cli/fleet-sync-accounts.ts +58 -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 +84 -12
- package/src/cli/fleet-sync-monitoring.ts +5 -5
- package/src/cli/fleet-sync-output.ts +7 -3
- package/src/cli/fleet-sync-remote.ts +31 -18
- package/src/cli/fleet-sync-state.ts +86 -0
- package/src/cli/fleet-sync.ts +159 -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 +86 -1
- package/src/cli/registry.ts +41 -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 +116 -4
- package/src/diagnostics.ts +1 -1
- package/src/fleet-config.ts +105 -19
- package/src/ide/definition.ts +86 -0
- package/src/ide/ide.ts +43 -0
- package/src/ide/index.ts +5 -0
- package/src/ide/orchestrate.ts +50 -0
- package/src/ide/t3.ts +111 -0
- package/src/machine/source.ts +3 -3
- package/src/machine/state.ts +15 -27
- package/src/manager-sync.ts +9 -18
- 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 +30 -7
- package/src/package-info.ts +45 -0
- package/src/platform/ansible.ts +19 -15
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- package/src/platform/resource-verify.ts +8 -4
- package/src/platform/t3-policy.ts +33 -6
- package/src/platform/t3-service.ts +104 -0
- package/src/platform/types.ts +0 -4
- package/src/platform/ubuntu-audit.ts +5 -2
- package/src/platform/windows.ts +0 -14
- package/src/remote-bootstrap.ts +17 -19
- package/src/remote-entry.ts +15 -1
- package/src/repair-catalog.ts +1 -1
- package/src/runner.ts +1 -1
- package/src/service-auth.ts +15 -5
- 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/accounts.ts +7 -1
- package/src/t3/models.ts +133 -0
- package/src/t3/pairing.ts +31 -0
- package/src/t3/profile.ts +203 -0
- package/src/t3/version.ts +119 -0
- package/src/t3-endpoint.ts +64 -8
- package/src/t3-linked-check.ts +24 -2
- package/src/toolchain-checks.ts +25 -0
- package/src/transport.ts +3 -2
- package/src/types.ts +4 -0
- package/src/worker-access-checks.ts +56 -3
- package/src/worker-host-checks.ts +41 -13
- package/src/workstation-checks.ts +1 -1
- package/src/index.ts +0 -6
- package/src/test-fixtures.ts +0 -36
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Check, Status } from '../types';
|
|
2
|
+
import { DIVERGED_REMEDY, type Standing } from './sync';
|
|
3
|
+
|
|
4
|
+
const INIT =
|
|
5
|
+
'Name a bucket under "state" in fleet.json and pass its credentials in DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY (see docs/state.md); without a fleet.json, devfleet fleet-state --init --bucket NAME --endpoint https://HOST. Until then a lost manager loses the roster, the approved revision and the configuration last applied.';
|
|
6
|
+
|
|
7
|
+
const FINDINGS: Record<
|
|
8
|
+
Standing,
|
|
9
|
+
{ status: Status; summary: string; action?: string }
|
|
10
|
+
> = {
|
|
11
|
+
'in-sync': {
|
|
12
|
+
status: 'pass',
|
|
13
|
+
summary: 'The bucket holds the fleet state this machine works from.',
|
|
14
|
+
},
|
|
15
|
+
'local-only': {
|
|
16
|
+
status: 'warn',
|
|
17
|
+
summary:
|
|
18
|
+
'No bucket is configured: this machine holds the only copy of the fleet state.',
|
|
19
|
+
action: INIT,
|
|
20
|
+
},
|
|
21
|
+
empty: {
|
|
22
|
+
status: 'warn',
|
|
23
|
+
summary: 'Neither this machine nor the bucket holds any fleet state yet.',
|
|
24
|
+
action:
|
|
25
|
+
'Run devfleet fleet-sync from the project that holds your fleet.json: it records the configuration, the roster and the approved revision. devfleet fleet-state --example prints a fleet.json to start from.',
|
|
26
|
+
},
|
|
27
|
+
unpublished: {
|
|
28
|
+
status: 'warn',
|
|
29
|
+
summary:
|
|
30
|
+
'The bucket holds no fleet state yet; this machine has the only copy.',
|
|
31
|
+
action: 'Upload it: devfleet fleet-state --push.',
|
|
32
|
+
},
|
|
33
|
+
'local-ahead': {
|
|
34
|
+
status: 'warn',
|
|
35
|
+
summary: 'This machine has fleet state the bucket has not received.',
|
|
36
|
+
action: 'Upload it: devfleet fleet-state --push.',
|
|
37
|
+
},
|
|
38
|
+
'bucket-ahead': {
|
|
39
|
+
status: 'warn',
|
|
40
|
+
summary:
|
|
41
|
+
'The bucket holds a newer fleet state than this machine; another manager changed it.',
|
|
42
|
+
action:
|
|
43
|
+
'The next fleet-sync takes it automatically; devfleet fleet-state --restore takes it now.',
|
|
44
|
+
},
|
|
45
|
+
unreachable: {
|
|
46
|
+
status: 'fail',
|
|
47
|
+
summary:
|
|
48
|
+
'The bucket did not answer, so the fleet state could not be compared or uploaded.',
|
|
49
|
+
action:
|
|
50
|
+
'Check the network, the bucket named under "state" in fleet.json (or saved in bucket.json) and its credentials: DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY for this command, or the ones devfleet fleet-state --init saved on this machine. Then run devfleet fleet-state --push, which also installs the pinned Litestream when it is missing (that needs mise, which the first fleet-sync on a machine installs). Details are withheld to protect credentials.',
|
|
51
|
+
},
|
|
52
|
+
diverged: {
|
|
53
|
+
status: 'fail',
|
|
54
|
+
summary: 'This machine and the bucket hold different fleet states.',
|
|
55
|
+
action: DIVERGED_REMEDY,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Whether the bucket holds this machine's fleet state. `advisory` reports a
|
|
60
|
+
* problem as a warning: for a run that succeeded in everything else.
|
|
61
|
+
*/
|
|
62
|
+
export function replicaCheck(
|
|
63
|
+
standing: Standing,
|
|
64
|
+
options: { advisory?: boolean } = {}
|
|
65
|
+
): Check {
|
|
66
|
+
const finding = FINDINGS[standing];
|
|
67
|
+
return {
|
|
68
|
+
id: 'state:replica',
|
|
69
|
+
status:
|
|
70
|
+
options.advisory && finding.status === 'fail' ? 'warn' : finding.status,
|
|
71
|
+
summary: finding.summary,
|
|
72
|
+
...(finding.action ? { remedy: { action: finding.action } } : {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import { declaredState } from '../fleet-config';
|
|
4
|
+
import {
|
|
5
|
+
ACCESS_KEY_ID_ENV,
|
|
6
|
+
SECRET_ACCESS_KEY_ENV,
|
|
7
|
+
bucketIdentity,
|
|
8
|
+
parseLocation,
|
|
9
|
+
readBucket,
|
|
10
|
+
withCredentials,
|
|
11
|
+
type Bucket,
|
|
12
|
+
} from './bucket';
|
|
13
|
+
import { bucketPath } from './paths';
|
|
14
|
+
|
|
15
|
+
/** How a devfleet command reaches the bucket, and where each part came from.
|
|
16
|
+
* Never a credential itself.
|
|
17
|
+
*/
|
|
18
|
+
export interface Connection {
|
|
19
|
+
bucket: Bucket | null;
|
|
20
|
+
/** `configuration`: the `state` block of the fleet configuration.
|
|
21
|
+
* `saved`: this machine's bucket.json.
|
|
22
|
+
*/
|
|
23
|
+
location: 'configuration' | 'saved' | null;
|
|
24
|
+
/** `environment`: the two `DEVFLEET_BUCKET_*` variables. `saved`: this
|
|
25
|
+
* machine's bucket.json. `ambient`: none given, so the standard AWS
|
|
26
|
+
* environment or profile.
|
|
27
|
+
*/
|
|
28
|
+
credentials: 'environment' | 'saved' | 'ambient' | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The credentials passed to this command, or null. */
|
|
32
|
+
export function environmentCredentials(
|
|
33
|
+
env: NodeJS.ProcessEnv
|
|
34
|
+
): { accessKeyId: string; secretAccessKey: string } | null {
|
|
35
|
+
const accessKeyId = env[ACCESS_KEY_ID_ENV];
|
|
36
|
+
const secretAccessKey = env[SECRET_ACCESS_KEY_ENV];
|
|
37
|
+
if (!accessKeyId && !secretAccessKey) return null;
|
|
38
|
+
if (!accessKeyId || !secretAccessKey)
|
|
39
|
+
throw new FleetUsageError(
|
|
40
|
+
`Set both ${ACCESS_KEY_ID_ENV} and ${SECRET_ACCESS_KEY_ENV}, or neither. Credentials are never taken from arguments.`
|
|
41
|
+
);
|
|
42
|
+
return { accessKeyId, secretAccessKey };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The configuration says where the bucket is; credentials are passed to the
|
|
46
|
+
* command. A machine that runs unattended (the schedule, a hand-off from a
|
|
47
|
+
* worker) gets no environment, so `fleet-state --init` may save both there:
|
|
48
|
+
* a saved location is used when the configuration names none, and saved
|
|
49
|
+
* credentials only for the bucket they were saved for.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveConnection(
|
|
52
|
+
declared: Bucket | null,
|
|
53
|
+
saved: Bucket | null,
|
|
54
|
+
env: NodeJS.ProcessEnv
|
|
55
|
+
): Connection {
|
|
56
|
+
const target = declared ?? saved;
|
|
57
|
+
if (!target) return { bucket: null, location: null, credentials: null };
|
|
58
|
+
const location = declared ? 'configuration' : 'saved';
|
|
59
|
+
if (target.kind === 'directory')
|
|
60
|
+
return { bucket: target, location, credentials: null };
|
|
61
|
+
const given = environmentCredentials(env);
|
|
62
|
+
if (given)
|
|
63
|
+
return {
|
|
64
|
+
bucket: withCredentials(target, given),
|
|
65
|
+
location,
|
|
66
|
+
credentials: 'environment',
|
|
67
|
+
};
|
|
68
|
+
if (
|
|
69
|
+
saved?.kind === 's3' &&
|
|
70
|
+
saved.accessKeyId &&
|
|
71
|
+
bucketIdentity(saved) === bucketIdentity(target)
|
|
72
|
+
)
|
|
73
|
+
return {
|
|
74
|
+
bucket: withCredentials(target, saved),
|
|
75
|
+
location,
|
|
76
|
+
credentials: 'saved',
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
bucket: withCredentials(target, {}),
|
|
80
|
+
location,
|
|
81
|
+
credentials: 'ambient',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function connection(env = process.env, home = homedir()): Connection {
|
|
86
|
+
const declared = declaredState(env, home);
|
|
87
|
+
return resolveConnection(
|
|
88
|
+
declared ? parseLocation(declared) : null,
|
|
89
|
+
readBucket(bucketPath(env, home)),
|
|
90
|
+
env
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import {
|
|
4
|
+
parseEnrollment,
|
|
5
|
+
readEnrollment,
|
|
6
|
+
type Enrollment,
|
|
7
|
+
} from '../machine/state';
|
|
8
|
+
import { adoptEarlier } from './legacy';
|
|
9
|
+
import { databasePath, stateRoot } from './paths';
|
|
10
|
+
import { readDocument, writeDocument } from './store';
|
|
11
|
+
|
|
12
|
+
/** The manager's approved and applied revision, from the fleet state. A
|
|
13
|
+
* machine that has no state database yet reads the file that held it before
|
|
14
|
+
* (`enrollment.json` in `earlier`); its first approval moves it.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadEnrollment(
|
|
17
|
+
earlier: string | null,
|
|
18
|
+
env = process.env
|
|
19
|
+
): Promise<Enrollment | null> {
|
|
20
|
+
const database = databasePath(stateRoot(env));
|
|
21
|
+
if (earlier && !existsSync(database)) return readEnrollment(earlier);
|
|
22
|
+
const stored = readDocument(database, 'enrollment');
|
|
23
|
+
if (stored === null) return null;
|
|
24
|
+
let value: unknown;
|
|
25
|
+
try {
|
|
26
|
+
value = JSON.parse(stored);
|
|
27
|
+
} catch {
|
|
28
|
+
value = null;
|
|
29
|
+
}
|
|
30
|
+
const enrollment = parseEnrollment(value);
|
|
31
|
+
if (!enrollment)
|
|
32
|
+
throw new FleetUsageError(
|
|
33
|
+
'Invalid enrollment in the fleet state. Restore it from the bucket with devfleet fleet-state --restore; do not enroll a replacement using another machine’s identity.'
|
|
34
|
+
);
|
|
35
|
+
return enrollment;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function storeEnrollment(state: Enrollment, env = process.env): void {
|
|
39
|
+
adoptEarlier(env);
|
|
40
|
+
writeDocument(
|
|
41
|
+
databasePath(stateRoot(env)),
|
|
42
|
+
'enrollment',
|
|
43
|
+
JSON.stringify(state) + '\n'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseConfig } from '../config';
|
|
5
|
+
import { parseDefinition } from '../definition';
|
|
6
|
+
import { parseEnrollment } from '../machine/state';
|
|
7
|
+
import { databasePath, definitionPath, legacyHome, stateRoot } from './paths';
|
|
8
|
+
import { DOCUMENTS, writeDocument, type DocumentName } from './store';
|
|
9
|
+
|
|
10
|
+
/** Where each document was kept before the fleet state existed. A machine
|
|
11
|
+
* that has no state database yet still reads them; its first change moves
|
|
12
|
+
* all of them into the state, and nothing reads or writes them afterwards.
|
|
13
|
+
*/
|
|
14
|
+
const LEGACY: Record<DocumentName, string> = {
|
|
15
|
+
// The copy fleet-sync installs; it stays the launchers' copy afterwards.
|
|
16
|
+
definition: '.config/dev-fleet/fleet.json',
|
|
17
|
+
roster: '.config/personal/fleet.json',
|
|
18
|
+
enrollment: '.local/share/personal/fleet/manager/enrollment.json',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function legacyPath(
|
|
22
|
+
name: DocumentName,
|
|
23
|
+
env = process.env,
|
|
24
|
+
home = homedir()
|
|
25
|
+
): string | null {
|
|
26
|
+
const earlier = legacyHome(env, home);
|
|
27
|
+
return earlier ? join(earlier, LEGACY[name]) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The earlier file's text, or null: when there is no such file, and once
|
|
31
|
+
* this machine has a state database, which then holds everything.
|
|
32
|
+
*/
|
|
33
|
+
export function readLegacy(
|
|
34
|
+
name: DocumentName,
|
|
35
|
+
env = process.env,
|
|
36
|
+
home = homedir()
|
|
37
|
+
): string | null {
|
|
38
|
+
const path = legacyPath(name, env, home);
|
|
39
|
+
if (!path || existsSync(databasePath(stateRoot(env, home)))) return null;
|
|
40
|
+
try {
|
|
41
|
+
const info = lstatSync(path);
|
|
42
|
+
if (!info.isFile() || info.size > 1024 * 1024) return null;
|
|
43
|
+
return readFileSync(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a document's text is what its readers accept. */
|
|
50
|
+
const VALID: Record<DocumentName, (value: unknown) => boolean> = {
|
|
51
|
+
definition: (value) => Boolean(parseDefinition(value)),
|
|
52
|
+
roster: (value) => Boolean(parseConfig(value)),
|
|
53
|
+
enrollment: (value) => parseEnrollment(value) !== null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function valid(name: DocumentName, text: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
return VALID[name](JSON.parse(text));
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run before the first change on a machine. A state database that does not
|
|
65
|
+
* exist yet starts with what the machine kept in files, so creating it never
|
|
66
|
+
* hides a document the machine still depends on. Returns what was moved.
|
|
67
|
+
*/
|
|
68
|
+
export function adoptEarlier(
|
|
69
|
+
env = process.env,
|
|
70
|
+
home = homedir()
|
|
71
|
+
): DocumentName[] {
|
|
72
|
+
const root = stateRoot(env, home);
|
|
73
|
+
if (existsSync(databasePath(root))) return [];
|
|
74
|
+
const named = env['DEVFLEET_CONFIG'];
|
|
75
|
+
const earlier = DOCUMENTS.map((name) => ({
|
|
76
|
+
name,
|
|
77
|
+
text: readLegacy(name, env, home),
|
|
78
|
+
}));
|
|
79
|
+
const adopted: DocumentName[] = [];
|
|
80
|
+
for (const { name, text } of earlier) {
|
|
81
|
+
// A configuration kept in a file of your own stays there.
|
|
82
|
+
if (name === 'definition' && named && named !== definitionPath(root))
|
|
83
|
+
continue;
|
|
84
|
+
if (text === null || !valid(name, text)) continue;
|
|
85
|
+
writeDocument(databasePath(root), name, text);
|
|
86
|
+
adopted.push(name);
|
|
87
|
+
}
|
|
88
|
+
return adopted;
|
|
89
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { pinnedPath } from '../agents/toolchain';
|
|
5
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
6
|
+
import type { Runner } from '../types';
|
|
7
|
+
import type { Bucket } from './bucket';
|
|
8
|
+
|
|
9
|
+
/** Where this code runs from: its mise.toml pins Litestream. */
|
|
10
|
+
const SOURCE = PACKAGE_ROOT;
|
|
11
|
+
|
|
12
|
+
export interface Replication {
|
|
13
|
+
database: string;
|
|
14
|
+
bucket: Bucket;
|
|
15
|
+
run: Runner;
|
|
16
|
+
/** False for a read-only command: it does not install the binary. */
|
|
17
|
+
install?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const located = new WeakMap<Runner, Promise<string | null>>();
|
|
21
|
+
|
|
22
|
+
/** The pinned Litestream binary. It is a lazy mise pin, installed the first
|
|
23
|
+
* time the manager needs it; workers never do.
|
|
24
|
+
*/
|
|
25
|
+
function binary(run: Runner, install: boolean): Promise<string | null> {
|
|
26
|
+
// An installed package is a directory nobody ran `mise trust` in. Its pins
|
|
27
|
+
// are trusted for these commands only; nothing is recorded on the machine.
|
|
28
|
+
const options = {
|
|
29
|
+
cwd: SOURCE,
|
|
30
|
+
env: { PATH: pinnedPath(), MISE_TRUSTED_CONFIG_PATHS: SOURCE },
|
|
31
|
+
};
|
|
32
|
+
const locate = async (): Promise<string | null> => {
|
|
33
|
+
const which = await run(['mise', 'which', 'litestream'], options);
|
|
34
|
+
const path = which.stdout.trim();
|
|
35
|
+
return which.ok && path.startsWith('/') ? path : null;
|
|
36
|
+
};
|
|
37
|
+
const known = located.get(run);
|
|
38
|
+
if (known) return known;
|
|
39
|
+
const found = locate().then(async (path) => {
|
|
40
|
+
if (path || !install) return path;
|
|
41
|
+
await run(['mise', 'install', 'litestream'], {
|
|
42
|
+
...options,
|
|
43
|
+
timeoutMs: 180_000,
|
|
44
|
+
});
|
|
45
|
+
return locate();
|
|
46
|
+
});
|
|
47
|
+
// A missing binary is looked for again: a later command may install it.
|
|
48
|
+
void found.then((path) => {
|
|
49
|
+
if (!path) located.delete(run);
|
|
50
|
+
});
|
|
51
|
+
located.set(run, found);
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function replica(database: string, bucket: Bucket): Record<string, unknown> {
|
|
56
|
+
if (bucket.kind === 'directory')
|
|
57
|
+
return { type: 'file', path: join(bucket.path, basename(database)) };
|
|
58
|
+
return {
|
|
59
|
+
type: 's3',
|
|
60
|
+
bucket: bucket.bucket,
|
|
61
|
+
path: `${bucket.prefix}/${basename(database)}`,
|
|
62
|
+
...(bucket.region ? { region: bucket.region } : {}),
|
|
63
|
+
// Every S3-compatible service answers path-style requests.
|
|
64
|
+
...(bucket.endpoint
|
|
65
|
+
? { endpoint: bucket.endpoint, 'force-path-style': true }
|
|
66
|
+
: {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The Litestream configuration for one database. It never holds a
|
|
71
|
+
* credential: those reach Litestream through its environment.
|
|
72
|
+
*/
|
|
73
|
+
export function litestreamConfig(database: string, bucket: Bucket): string {
|
|
74
|
+
return stringify({
|
|
75
|
+
logging: { level: 'error' },
|
|
76
|
+
// Each change uploads a full snapshot (the database is a few kilobytes),
|
|
77
|
+
// so a restore reads one object however long the history is.
|
|
78
|
+
snapshot: { interval: '24h', retention: '720h' },
|
|
79
|
+
dbs: [{ path: database, replica: replica(database, bucket) }],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function litestreamEnvironment(bucket: Bucket): Record<string, string> {
|
|
84
|
+
return bucket.kind === 's3' && bucket.accessKeyId && bucket.secretAccessKey
|
|
85
|
+
? {
|
|
86
|
+
// The standard names: they replace any keys in the operator's shell,
|
|
87
|
+
// which Litestream would otherwise prefer.
|
|
88
|
+
AWS_ACCESS_KEY_ID: bucket.accessKeyId,
|
|
89
|
+
AWS_SECRET_ACCESS_KEY: bucket.secretAccessKey,
|
|
90
|
+
// These keys are not a session's: a token left in the shell must
|
|
91
|
+
// not be sent with them.
|
|
92
|
+
AWS_SESSION_TOKEN: '',
|
|
93
|
+
}
|
|
94
|
+
: {};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Litestream is run once per change rather than as a daemon: a devfleet
|
|
98
|
+
* command that changed the state has not finished until the bucket has it,
|
|
99
|
+
* and nothing has to be kept running on the manager.
|
|
100
|
+
*/
|
|
101
|
+
async function invoke(
|
|
102
|
+
{ database, bucket, run, install }: Replication,
|
|
103
|
+
command: string,
|
|
104
|
+
args: string[]
|
|
105
|
+
): Promise<boolean> {
|
|
106
|
+
const litestream = await binary(run, install !== false);
|
|
107
|
+
if (!litestream) return false;
|
|
108
|
+
const config = join(dirname(database), 'litestream.yml');
|
|
109
|
+
mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
|
|
110
|
+
writeFileSync(config, litestreamConfig(database, bucket), { mode: 0o600 });
|
|
111
|
+
const result = await run(
|
|
112
|
+
[litestream, command, '-no-expand-env', '-config', config, ...args],
|
|
113
|
+
// A bucket that does not answer must not hold a fleet-sync up for long.
|
|
114
|
+
{ timeoutMs: 60_000, env: litestreamEnvironment(bucket) }
|
|
115
|
+
);
|
|
116
|
+
return result.ok;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Uploads everything the bucket does not have yet. */
|
|
120
|
+
export async function replicate(target: Replication): Promise<boolean> {
|
|
121
|
+
return invoke(target, 'replicate', [
|
|
122
|
+
'-once',
|
|
123
|
+
'-force-snapshot',
|
|
124
|
+
'-enforce-retention',
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Downloads the bucket's latest version to `output`, which must not exist.
|
|
129
|
+
* `absent` means the bucket is reachable and holds no state yet.
|
|
130
|
+
*/
|
|
131
|
+
export async function restore(
|
|
132
|
+
target: Replication,
|
|
133
|
+
output: string
|
|
134
|
+
): Promise<'restored' | 'absent' | 'failed'> {
|
|
135
|
+
const ran = await invoke(target, 'restore', [
|
|
136
|
+
'-if-replica-exists',
|
|
137
|
+
'-o',
|
|
138
|
+
output,
|
|
139
|
+
target.database,
|
|
140
|
+
]);
|
|
141
|
+
if (!ran) return 'failed';
|
|
142
|
+
return existsSync(output) ? 'restored' : 'absent';
|
|
143
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Reads the `definition` document from the fleet state under `root`. */
|
|
2
|
+
type DefinitionReader = (root: string) => string | null;
|
|
3
|
+
|
|
4
|
+
let reader: DefinitionReader | null = null;
|
|
5
|
+
|
|
6
|
+
/** Called by the devfleet CLI. Only it reads the fleet's state directly, so
|
|
7
|
+
* a change to the configuration reaches the manager's launchers and
|
|
8
|
+
* scheduled jobs the way it reaches every worker: as the copy fleet-sync
|
|
9
|
+
* installs. The reader is handed in rather than imported because launchers
|
|
10
|
+
* and the worker probe load the configuration under Node, which cannot open
|
|
11
|
+
* the state database. Null undoes it (tests).
|
|
12
|
+
*/
|
|
13
|
+
export function actAsOperator(read: DefinitionReader | null): void {
|
|
14
|
+
reader = read;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const isOperator = (): boolean => reader !== null;
|
|
18
|
+
|
|
19
|
+
export const stateDefinition = (root: string): string | null =>
|
|
20
|
+
reader?.(root) ?? null;
|
|
@@ -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
|
+
}
|