@crvouga/dev-fleet 0.2.0 → 0.4.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 +50 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/manager.yml +3 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/macos/t3-policy.yml +49 -0
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +51 -1
- 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/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/recovery.yml +60 -1
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
- package/ansible/ubuntu/wip-guard.py +121 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/native-t3.ts +22 -11
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +4 -4
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/accounts.py +216 -0
- package/cliproxy/scripts/checkpoint.py +30 -2
- 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/authentication.md +90 -0
- package/docs/declared.md +5 -1
- package/docs/durability.md +26 -0
- package/docs/machine-setup.md +26 -15
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/topology.md +2 -0
- package/docs/ubuntu-sync.md +35 -28
- package/examples/fleet.json +19 -1
- package/llms.txt +3 -0
- package/mise.toml +10 -1
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +244 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- 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/launch.ts +84 -5
- package/src/agents/native-verify.ts +128 -2
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-accounts.ts +253 -0
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-accounts.ts +58 -0
- package/src/cli/fleet-sync-manager.ts +31 -4
- package/src/cli/fleet-sync-output.ts +3 -2
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +23 -14
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +126 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +86 -0
- package/src/cli/registry.ts +26 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +226 -7
- package/src/git-identity.ts +160 -0
- 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-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +13 -17
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +44 -12
- package/src/notify.ts +239 -0
- package/src/platform/darwin.ts +0 -13
- package/src/platform/fallback.ts +0 -8
- package/src/platform/linux.ts +0 -10
- 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/windows.ts +0 -14
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-entry.ts +15 -1
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/service-auth.ts +15 -5
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- 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-linked-check.ts +1 -1
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/transport.ts +3 -2
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +68 -13
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
package/src/ide/t3.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import type { Check, FleetNode } from '../types';
|
|
4
|
+
import type { Ide, MachineContext, WorkerContext } from './ide';
|
|
5
|
+
import { fleet } from '../fleet-config';
|
|
6
|
+
import { machineProviders } from '../t3/providers';
|
|
7
|
+
import { providerNames } from '../t3/names-check';
|
|
8
|
+
import { profileCheck } from '../t3/profile';
|
|
9
|
+
import { modelsCheck } from '../t3/models';
|
|
10
|
+
import { proxyAccounts, type Roster } from '../t3/accounts';
|
|
11
|
+
import {
|
|
12
|
+
MACOS_POLICY,
|
|
13
|
+
managerVersion,
|
|
14
|
+
updatesCheck,
|
|
15
|
+
workerVersion,
|
|
16
|
+
} from '../t3/version';
|
|
17
|
+
import { verifyT3Endpoint } from '../t3-endpoint';
|
|
18
|
+
import { observedIdentity, verifyT3Linked } from '../t3-linked-check';
|
|
19
|
+
import { T3_RESTARTED_SUMMARY } from '../platform/t3-policy';
|
|
20
|
+
import { ansibleCommand } from '../platform/ansible';
|
|
21
|
+
|
|
22
|
+
/** Where T3 keeps its data on this machine. */
|
|
23
|
+
const t3Home = (): string =>
|
|
24
|
+
resolve(process.env['T3CODE_HOME'] ?? join(homedir(), '.t3'));
|
|
25
|
+
|
|
26
|
+
/** What the T3 checks read besides the machine: injected by scenarios. */
|
|
27
|
+
export interface T3Inputs {
|
|
28
|
+
t3Home?: string;
|
|
29
|
+
roster?: Roster;
|
|
30
|
+
profile?: Record<string, unknown>;
|
|
31
|
+
requiredModels?: Record<string, string[]>;
|
|
32
|
+
policyPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** T3 Code, the first supported IDE. Its install, service and policy on
|
|
36
|
+
* workers are the `ansible/ubuntu/t3*` playbooks and on the manager
|
|
37
|
+
* `ansible/macos/t3-policy.yml`; everything else is reached from here.
|
|
38
|
+
*/
|
|
39
|
+
export function t3Ide(inputs: T3Inputs = {}): Ide {
|
|
40
|
+
const home = () => inputs.t3Home ?? t3Home();
|
|
41
|
+
const settings = async (context: MachineContext): Promise<Check[]> => {
|
|
42
|
+
const required = inputs.requiredModels ?? fleet.t3?.requiredModels;
|
|
43
|
+
const options = {
|
|
44
|
+
t3Home: home(),
|
|
45
|
+
...(inputs.roster ? { roster: inputs.roster } : {}),
|
|
46
|
+
};
|
|
47
|
+
return [
|
|
48
|
+
await machineProviders(
|
|
49
|
+
context.id,
|
|
50
|
+
context.workspace,
|
|
51
|
+
context.apply,
|
|
52
|
+
options
|
|
53
|
+
),
|
|
54
|
+
await providerNames(
|
|
55
|
+
context.id,
|
|
56
|
+
context.workspace,
|
|
57
|
+
context.apply,
|
|
58
|
+
options
|
|
59
|
+
),
|
|
60
|
+
await profileCheck(
|
|
61
|
+
join(home(), 'userdata/settings.json'),
|
|
62
|
+
inputs.profile ?? fleet.t3?.profile ?? {},
|
|
63
|
+
{ apply: context.apply }
|
|
64
|
+
),
|
|
65
|
+
await modelsCheck({
|
|
66
|
+
t3Home: home(),
|
|
67
|
+
roster: inputs.roster ?? proxyAccounts,
|
|
68
|
+
...(required ? { required } : {}),
|
|
69
|
+
}),
|
|
70
|
+
];
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
id: 't3',
|
|
74
|
+
settings,
|
|
75
|
+
async manager(context) {
|
|
76
|
+
return [
|
|
77
|
+
...(await settings(context)),
|
|
78
|
+
await managerVersion(home(), context.workspace),
|
|
79
|
+
await updatesCheck(
|
|
80
|
+
inputs.policyPath ?? MACOS_POLICY,
|
|
81
|
+
`cd ${context.workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`
|
|
82
|
+
),
|
|
83
|
+
];
|
|
84
|
+
},
|
|
85
|
+
async worker(node: FleetNode, context: WorkerContext) {
|
|
86
|
+
const checks = [...(await verifyT3Endpoint(node, context.run))];
|
|
87
|
+
// A worker T3 restarted by this sync (idle policy heal) needs a minute
|
|
88
|
+
// or two before the manager's link reconnects.
|
|
89
|
+
const restarted = context.earlier.some(
|
|
90
|
+
(check) => check.summary === T3_RESTARTED_SUMMARY
|
|
91
|
+
);
|
|
92
|
+
const linked = await verifyT3Linked(node, context.run, {
|
|
93
|
+
reconnectMs: restarted ? 180_000 : 0,
|
|
94
|
+
});
|
|
95
|
+
checks.push(linked);
|
|
96
|
+
if (
|
|
97
|
+
linked.status !== 'fail' &&
|
|
98
|
+
!node.t3EnvironmentId &&
|
|
99
|
+
context.remember
|
|
100
|
+
) {
|
|
101
|
+
const identity = await observedIdentity(node, context.run).catch(
|
|
102
|
+
() => undefined
|
|
103
|
+
);
|
|
104
|
+
if (identity)
|
|
105
|
+
await context.remember(node.id, identity).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
checks.push(await workerVersion(node, context.run, context.workspace));
|
|
108
|
+
return checks;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/machine-health.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
1
2
|
import type { DoctorCheck } from './doctor-check';
|
|
2
3
|
|
|
3
4
|
/** Machine health sample parsed from `net/remote-probe.sh` output. */
|
|
@@ -7,6 +8,14 @@ export type HealthSample = {
|
|
|
7
8
|
readonly memFreePct?: number;
|
|
8
9
|
readonly swapPct?: number;
|
|
9
10
|
readonly diskMb?: number;
|
|
11
|
+
/** Free inodes on the home filesystem, as a percentage of all of them. */
|
|
12
|
+
readonly inodesFreePct?: number;
|
|
13
|
+
/** 1 when the clock is synchronised to a time source, 0 when not. */
|
|
14
|
+
readonly clockSynced?: number;
|
|
15
|
+
/** 1 when a time service is enabled, 0 when not. */
|
|
16
|
+
readonly timeService?: number;
|
|
17
|
+
/** The machine's clock, in seconds since the epoch. */
|
|
18
|
+
readonly epoch?: number;
|
|
10
19
|
};
|
|
11
20
|
|
|
12
21
|
const PROBE_KEYS = {
|
|
@@ -15,6 +24,10 @@ const PROBE_KEYS = {
|
|
|
15
24
|
memfree: 'memFreePct',
|
|
16
25
|
swap: 'swapPct',
|
|
17
26
|
diskmb: 'diskMb',
|
|
27
|
+
inodefree: 'inodesFreePct',
|
|
28
|
+
ntpsync: 'clockSynced',
|
|
29
|
+
ntp: 'timeService',
|
|
30
|
+
epoch: 'epoch',
|
|
18
31
|
} as const;
|
|
19
32
|
|
|
20
33
|
function isProbeKey(key: string): key is keyof typeof PROBE_KEYS {
|
|
@@ -33,7 +46,12 @@ export function parseProbe(output: string): HealthSample {
|
|
|
33
46
|
return sample;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
const LABELS = {
|
|
49
|
+
const LABELS = {
|
|
50
|
+
load: 'CPU load',
|
|
51
|
+
memory: 'Memory',
|
|
52
|
+
disk: 'Disk space',
|
|
53
|
+
inodes: 'Free inodes',
|
|
54
|
+
};
|
|
37
55
|
|
|
38
56
|
function check(
|
|
39
57
|
id: keyof typeof LABELS,
|
|
@@ -88,8 +106,37 @@ export function diskCheck(diskMb: number | undefined): DoctorCheck | null {
|
|
|
88
106
|
return check('disk', 'pass', `${Math.floor(diskMb / 1024)} GB free`);
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
/** Free inodes, with the floors `policy.json` gives the disk guard: a
|
|
110
|
+
* filesystem with free bytes and no free inodes fails every write.
|
|
111
|
+
*/
|
|
112
|
+
export function inodesCheck(
|
|
113
|
+
inodesFreePct: number | undefined,
|
|
114
|
+
floors = INODE_FLOORS
|
|
115
|
+
): DoctorCheck | null {
|
|
116
|
+
if (inodesFreePct === undefined) return null;
|
|
117
|
+
const what = `${inodesFreePct}% of inodes free`;
|
|
118
|
+
if (inodesFreePct < floors.failPercent)
|
|
119
|
+
return check(
|
|
120
|
+
'inodes',
|
|
121
|
+
'fail',
|
|
122
|
+
`INODES_EXHAUSTED, ${what}. Every new file fails although bytes are free.`,
|
|
123
|
+
'Remove idle dependency trees and worktrees: the hourly disk collector does so under pressure, or delete them by hand.'
|
|
124
|
+
);
|
|
125
|
+
if (inodesFreePct < floors.warnPercent)
|
|
126
|
+
return check('inodes', 'warn', `low, ${what}`);
|
|
127
|
+
return check('inodes', 'pass', what);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const INODE_FLOORS = {
|
|
131
|
+
warnPercent: policy.resourceSafety.inodeWarnFreePercent,
|
|
132
|
+
failPercent: policy.resourceSafety.inodeFailFreePercent,
|
|
133
|
+
};
|
|
134
|
+
|
|
91
135
|
export function healthChecks(s: HealthSample, alias: string): DoctorCheck[] {
|
|
92
|
-
return [
|
|
93
|
-
(
|
|
94
|
-
|
|
136
|
+
return [
|
|
137
|
+
loadCheck(s, alias),
|
|
138
|
+
memoryCheck(s),
|
|
139
|
+
diskCheck(s.diskMb),
|
|
140
|
+
inodesCheck(s.inodesFreePct),
|
|
141
|
+
].filter((c): c is DoctorCheck => c !== null);
|
|
95
142
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import macos from '../ansible/macos/policy.json';
|
|
4
|
+
import type { Check, Runner } from './types';
|
|
5
|
+
|
|
6
|
+
/** Read-only measurements of the manager's operating system and disk, from
|
|
7
|
+
* the floors `ansible/macos/policy.json` declares. Nothing a command printed
|
|
8
|
+
* is reported: only what was concluded from it.
|
|
9
|
+
*/
|
|
10
|
+
export async function managerHealth(
|
|
11
|
+
run: Runner,
|
|
12
|
+
home = homedir()
|
|
13
|
+
): Promise<Check[]> {
|
|
14
|
+
return [
|
|
15
|
+
await managerRelease(run),
|
|
16
|
+
await managerUpdates(run),
|
|
17
|
+
await managerDisk(run),
|
|
18
|
+
await containerWatchdog(run, home),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The manager runs at least the macOS release the policy names. */
|
|
23
|
+
export async function managerRelease(run: Runner): Promise<Check> {
|
|
24
|
+
const id = 'manager:release';
|
|
25
|
+
const result = await run(['sw_vers', '-productVersion']);
|
|
26
|
+
const major = /^(\d+)(?:\.\d+)*$/.exec(result.stdout.trim())?.[1];
|
|
27
|
+
if (!result.ok || major === undefined)
|
|
28
|
+
return { id, status: 'skip', summary: 'MANAGER_RELEASE_UNOBSERVED' };
|
|
29
|
+
if (Number(major) >= macos.minimumMajorVersion)
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
status: 'pass',
|
|
33
|
+
summary: `The manager runs macOS ${major}, at least the required ${macos.minimumMajorVersion}.`,
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
status: 'fail',
|
|
38
|
+
summary: `MANAGER_RELEASE_UNSUPPORTED macOS ${major}, below ${macos.minimumMajorVersion}`,
|
|
39
|
+
remedy: {
|
|
40
|
+
action: `Update the manager to macOS ${macos.minimumMajorVersion} or later in System Settings > General > Software Update, then rerun fleet-sync.`,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pending macOS updates warn, and so does one that waits for a restart.
|
|
46
|
+
* `--no-scan` reads the list macOS already has: nothing is downloaded.
|
|
47
|
+
*/
|
|
48
|
+
export async function managerUpdates(run: Runner): Promise<Check> {
|
|
49
|
+
const id = 'manager:updates';
|
|
50
|
+
const result = await run(['softwareupdate', '--list', '--no-scan'], {
|
|
51
|
+
timeoutMs: 60_000,
|
|
52
|
+
});
|
|
53
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
54
|
+
if (!result.ok)
|
|
55
|
+
return { id, status: 'skip', summary: 'MANAGER_UPDATES_UNOBSERVED' };
|
|
56
|
+
const pending = (output.match(/^\s*\* Label:/gm) ?? []).length;
|
|
57
|
+
const restart = /Action:\s*restart/i.test(output);
|
|
58
|
+
if (pending === 0)
|
|
59
|
+
return { id, status: 'pass', summary: 'No macOS update is pending.' };
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
status: 'warn',
|
|
63
|
+
summary: restart
|
|
64
|
+
? `MANAGER_RESTART_PENDING ${pending} updates, at least one waits for a restart`
|
|
65
|
+
: `MANAGER_UPDATES_PENDING ${pending} updates`,
|
|
66
|
+
remedy: {
|
|
67
|
+
action:
|
|
68
|
+
'Install the pending updates in System Settings > General > Software Update at a time no worker depends on the manager, then rerun fleet-sync.',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const MIB = 1024;
|
|
74
|
+
const GIB = 1024 ** 3;
|
|
75
|
+
|
|
76
|
+
/** Free space on the data volume, and the space Docker Desktop holds. */
|
|
77
|
+
export async function managerDisk(run: Runner): Promise<Check> {
|
|
78
|
+
const id = 'manager:disk';
|
|
79
|
+
const df = await run(['df', '-Pk', '/System/Volumes/Data']);
|
|
80
|
+
const kib = Number(df.stdout.split('\n')[1]?.trim().split(/\s+/)[3]);
|
|
81
|
+
if (!df.ok || !Number.isFinite(kib))
|
|
82
|
+
return { id, status: 'skip', summary: 'MANAGER_DISK_UNOBSERVED' };
|
|
83
|
+
const free = Math.floor(kib / MIB);
|
|
84
|
+
const docker = await dockerBytes(run);
|
|
85
|
+
const dockerGiB = docker === null ? null : docker / GIB;
|
|
86
|
+
const held =
|
|
87
|
+
dockerGiB === null ? '' : `; Docker holds ${dockerGiB.toFixed(1)} GiB`;
|
|
88
|
+
const remedy = {
|
|
89
|
+
action:
|
|
90
|
+
'Free space on the manager: docker system prune removes unused images and build cache; empty large caches and downloads.',
|
|
91
|
+
};
|
|
92
|
+
if (free < macos.disk.failFreeMiB)
|
|
93
|
+
return {
|
|
94
|
+
id,
|
|
95
|
+
status: 'fail',
|
|
96
|
+
summary: `MANAGER_DISK_FULL ${free} MiB free${held}`,
|
|
97
|
+
remedy,
|
|
98
|
+
};
|
|
99
|
+
if (free < macos.disk.warnFreeMiB)
|
|
100
|
+
return {
|
|
101
|
+
id,
|
|
102
|
+
status: 'warn',
|
|
103
|
+
summary: `MANAGER_DISK_LOW ${free} MiB free${held}`,
|
|
104
|
+
remedy,
|
|
105
|
+
};
|
|
106
|
+
if (dockerGiB !== null && dockerGiB > macos.disk.dockerWarnGiB)
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
status: 'warn',
|
|
110
|
+
summary: `MANAGER_DOCKER_DISK_LARGE ${free} MiB free${held}`,
|
|
111
|
+
remedy,
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
status: 'pass',
|
|
116
|
+
summary: `${Math.floor(free / MIB)} GiB free on the data volume${held}.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const UNITS: Record<string, number> = {
|
|
121
|
+
B: 1,
|
|
122
|
+
kB: 1e3,
|
|
123
|
+
KB: 1e3,
|
|
124
|
+
MB: 1e6,
|
|
125
|
+
GB: 1e9,
|
|
126
|
+
TB: 1e12,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The bytes of images, containers, volumes and build cache, or null when
|
|
130
|
+
* Docker does not answer.
|
|
131
|
+
*/
|
|
132
|
+
async function dockerBytes(run: Runner): Promise<number | null> {
|
|
133
|
+
const result = await run(
|
|
134
|
+
['docker', 'system', 'df', '--format', '{{.Size}}'],
|
|
135
|
+
{ timeoutMs: 30_000 }
|
|
136
|
+
);
|
|
137
|
+
if (!result.ok) return null;
|
|
138
|
+
let total = 0;
|
|
139
|
+
for (const line of result.stdout.split('\n')) {
|
|
140
|
+
const match = /^([\d.]+)\s*([kKMGT]?B)$/.exec(line.trim());
|
|
141
|
+
if (match) total += Number(match[1]) * (UNITS[match[2]!] ?? 0);
|
|
142
|
+
}
|
|
143
|
+
return total;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What the container watchdog remembers (ansible/monitoring/container-watchdog.py). */
|
|
147
|
+
type WatchdogMemory = Record<
|
|
148
|
+
string,
|
|
149
|
+
{ restarts?: number[]; capped?: number | null }
|
|
150
|
+
>;
|
|
151
|
+
|
|
152
|
+
/** A container the watchdog stopped restarting is a restart storm: it fails,
|
|
153
|
+
* and one it restarted within the hour warns.
|
|
154
|
+
*/
|
|
155
|
+
export async function containerWatchdog(
|
|
156
|
+
run: Runner,
|
|
157
|
+
home: string,
|
|
158
|
+
now = Date.now()
|
|
159
|
+
): Promise<Check> {
|
|
160
|
+
const id = 'manager:container-watchdog';
|
|
161
|
+
const result = await run([
|
|
162
|
+
'cat',
|
|
163
|
+
join(home, 'Library/Caches/dev-fleet/container-watchdog.json'),
|
|
164
|
+
]);
|
|
165
|
+
let memory: WatchdogMemory;
|
|
166
|
+
try {
|
|
167
|
+
memory = JSON.parse(result.stdout) as WatchdogMemory;
|
|
168
|
+
} catch {
|
|
169
|
+
memory = {};
|
|
170
|
+
}
|
|
171
|
+
if (!result.ok || typeof memory !== 'object' || memory === null)
|
|
172
|
+
return { id, status: 'skip', summary: 'CONTAINER_WATCHDOG_UNOBSERVED' };
|
|
173
|
+
const hour = (time: unknown) =>
|
|
174
|
+
typeof time === 'number' && now / 1000 - time < 3600;
|
|
175
|
+
const names = Object.keys(memory).filter((name) =>
|
|
176
|
+
/^[A-Za-z0-9_.-]{1,128}$/.test(name)
|
|
177
|
+
);
|
|
178
|
+
const capped = names.filter((name) => hour(memory[name]?.capped));
|
|
179
|
+
const restarted = names.filter((name) =>
|
|
180
|
+
(memory[name]?.restarts ?? []).some(hour)
|
|
181
|
+
);
|
|
182
|
+
const remedy = {
|
|
183
|
+
action:
|
|
184
|
+
'Read the container watchdog log (~/Library/Application Support/dev-fleet/container-watchdog/watchdog.log) and the container logs, fix the cause, then docker restart the container.',
|
|
185
|
+
};
|
|
186
|
+
if (capped.length)
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
status: 'fail',
|
|
190
|
+
summary: `CONTAINER_RESTART_STORM ${capped.join(' ')}`,
|
|
191
|
+
remedy,
|
|
192
|
+
};
|
|
193
|
+
if (restarted.length)
|
|
194
|
+
return {
|
|
195
|
+
id,
|
|
196
|
+
status: 'warn',
|
|
197
|
+
summary: `CONTAINER_RESTARTED_UNHEALTHY ${restarted.join(' ')}`,
|
|
198
|
+
remedy,
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
status: 'pass',
|
|
203
|
+
summary: 'No container stayed unhealthy in the last hour.',
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/manager-sync.ts
CHANGED
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
type NodeReport,
|
|
7
7
|
type Runner,
|
|
8
8
|
} from './types';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
discoverTailnet,
|
|
11
|
+
keyExpiryCheck,
|
|
12
|
+
type TailnetComputer,
|
|
13
|
+
} from './tailscale';
|
|
10
14
|
import { matchComputers } from './tailnet-matching';
|
|
11
15
|
import { sshArgs, sshFailure } from './ssh';
|
|
12
16
|
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
@@ -102,7 +106,12 @@ export async function synchronizeManager(
|
|
|
102
106
|
ok: false,
|
|
103
107
|
applied: false,
|
|
104
108
|
desiredRevision: input.revision,
|
|
105
|
-
checks:
|
|
109
|
+
checks: [
|
|
110
|
+
...tailnet.checks,
|
|
111
|
+
...(tailnet.computers.some((peer) => peer.self)
|
|
112
|
+
? [keyExpiryCheck(tailnet.computers.find((peer) => peer.self))]
|
|
113
|
+
: []),
|
|
114
|
+
],
|
|
106
115
|
// Printed for people: the stable public name, never a tailnet address.
|
|
107
116
|
dashboardUrl: publicHubUrl(),
|
|
108
117
|
nodes: [],
|
|
@@ -322,6 +331,7 @@ async function inspectManagerNode(
|
|
|
322
331
|
else {
|
|
323
332
|
checks.push(...(await inspectBootstrap(node, run, machine.os)));
|
|
324
333
|
checks.push(...identityChecks(node, peer));
|
|
334
|
+
if (peer) checks.push(keyExpiryCheck(peer));
|
|
325
335
|
}
|
|
326
336
|
if (!peer || !peer.online)
|
|
327
337
|
checks.push({
|
|
@@ -385,21 +395,7 @@ function mayDeploy(result: ManagerNode, nodes: FleetNode[]): boolean {
|
|
|
385
395
|
*/
|
|
386
396
|
export function blocksPromotion(check: Check): boolean {
|
|
387
397
|
if (!blocking(check)) return false;
|
|
388
|
-
|
|
389
|
-
return false;
|
|
390
|
-
return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|ubuntu:boot-policy$)/.test(
|
|
398
|
+
return !/^(worker:|beszel:|provider:|agent:|proxy:|t3:end-to-end$|t3:paired-access$|t3:policy-loaded$|t3:running-version$|ubuntu:boot-policy$)/.test(
|
|
391
399
|
check.id
|
|
392
400
|
);
|
|
393
401
|
}
|
|
394
|
-
|
|
395
|
-
/** Native T3 outcomes that need an operator maintenance window (an active
|
|
396
|
-
* listener or running release must never be killed by routine sync). They are
|
|
397
|
-
* reported as failures but do not block independent OS policy on other nodes.
|
|
398
|
-
*/
|
|
399
|
-
const T3_MAINTENANCE = new Set([
|
|
400
|
-
'T3_LISTENER_CONFLICT',
|
|
401
|
-
'T3_ACTIVE_LISTENER_MIGRATION_REQUIRED',
|
|
402
|
-
'T3_MAINTENANCE_REQUIRED',
|
|
403
|
-
'T3_POLICY_RESTART_PENDING',
|
|
404
|
-
'T3_DESKTOP_MIGRATION_REQUIRED',
|
|
405
|
-
]);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { record } from '../config';
|
|
2
|
+
import type { HubBackups } from '../definition';
|
|
2
3
|
import type { Check } from '../types';
|
|
3
4
|
import {
|
|
4
5
|
LOCAL_HUB,
|
|
@@ -10,6 +11,10 @@ import {
|
|
|
10
11
|
|
|
11
12
|
/** A scheduled backup older than this means the schedule is not running. */
|
|
12
13
|
export const BACKUP_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
14
|
+
/** Declared backups that have not reached off-machine storage for this long
|
|
15
|
+
* fail: the grace covers a fresh hub and a storage outage of a few days.
|
|
16
|
+
*/
|
|
17
|
+
export const BACKUP_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
13
18
|
|
|
14
19
|
function backupCheck(
|
|
15
20
|
status: Check['status'],
|
|
@@ -24,21 +29,26 @@ function backupCheck(
|
|
|
24
29
|
};
|
|
25
30
|
}
|
|
26
31
|
|
|
27
|
-
const
|
|
28
|
-
'
|
|
32
|
+
const UNDECLARED =
|
|
33
|
+
'Declare services.monitoring.backups in fleet.json (a schedule, how many to keep and S3-compatible storage off the manager) and the secrets monitoring-backup-access-key and monitoring-backup-secret-key; fleet-sync then sets them through the hub.';
|
|
34
|
+
const DECLARED =
|
|
35
|
+
"fleet-sync sets the backups declared under services.monitoring.backups. Check that the secrets monitoring-backup-access-key and monitoring-backup-secret-key are readable on the manager and that the manager's Vault session is logged in, then rerun fleet-sync.";
|
|
29
36
|
|
|
30
|
-
/** Reads
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
37
|
+
/** Reads the hub's own backup settings and its list of backups through the
|
|
38
|
+
* stock PocketBase API of the pinned release (superuser only: GET
|
|
39
|
+
* /api/settings and GET /api/backups): whether they are scheduled, kept off
|
|
40
|
+
* the manager and recent. fleet-sync sets them from the fleet configuration
|
|
41
|
+
* (`hub-darwin.yml`); with them declared, anything but that is a failure, a
|
|
42
|
+
* stale backup after a grace period. Storage names, keys and endpoints are
|
|
43
|
+
* never reported.
|
|
35
44
|
*/
|
|
36
45
|
export async function verifyHubBackups(
|
|
37
46
|
login: HubLogin | null,
|
|
38
47
|
fetcher: typeof fetch = fetch,
|
|
39
48
|
base: string = LOCAL_HUB,
|
|
40
|
-
|
|
49
|
+
options: { now?: number; declared?: HubBackups | undefined } = {}
|
|
41
50
|
): Promise<Check> {
|
|
51
|
+
const SETTINGS = options.declared ? DECLARED : UNDECLARED;
|
|
42
52
|
if (!login)
|
|
43
53
|
return backupCheck(
|
|
44
54
|
'skip',
|
|
@@ -69,31 +79,67 @@ export async function verifyHubBackups(
|
|
|
69
79
|
'BESZEL_BACKUP_UNVERIFIED',
|
|
70
80
|
`The hub answered in a form this release does not expect. ${SETTINGS}`
|
|
71
81
|
);
|
|
82
|
+
return judgeBackups(
|
|
83
|
+
policy,
|
|
84
|
+
newestBackup(backups),
|
|
85
|
+
options.now ?? Date.now(),
|
|
86
|
+
options.declared
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Undeclared backups only warn; declared ones fail when they are not what
|
|
91
|
+
* the fleet configuration says, or have not reached storage for longer than
|
|
92
|
+
* the grace period.
|
|
93
|
+
*/
|
|
94
|
+
function judgeBackups(
|
|
95
|
+
policy: { cron: string; keep: number; offHost: boolean },
|
|
96
|
+
newest: number | null,
|
|
97
|
+
now: number,
|
|
98
|
+
declared: HubBackups | undefined
|
|
99
|
+
): Check {
|
|
100
|
+
const SETTINGS = declared ? DECLARED : UNDECLARED;
|
|
101
|
+
const missing: Check['status'] = declared ? 'fail' : 'warn';
|
|
72
102
|
if (!policy.cron)
|
|
73
103
|
return backupCheck(
|
|
74
|
-
|
|
104
|
+
missing,
|
|
75
105
|
'BESZEL_BACKUP_UNSCHEDULED',
|
|
76
|
-
`The hub takes no scheduled backups, so its history, settings and machine identities are lost with the manager. ${SETTINGS}
|
|
106
|
+
`The hub takes no scheduled backups, so its history, settings and machine identities are lost with the manager. ${SETTINGS}`
|
|
77
107
|
);
|
|
78
108
|
if (!policy.offHost)
|
|
79
109
|
return backupCheck(
|
|
80
|
-
|
|
110
|
+
missing,
|
|
81
111
|
'BESZEL_BACKUP_OFFHOST_REQUIRED',
|
|
82
|
-
`Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS}
|
|
112
|
+
`Scheduled backups stay on the manager's disk, which is not disaster recovery. ${SETTINGS} Use encrypted S3-compatible storage you already approved; do not create new storage or reuse unrelated credentials.`
|
|
83
113
|
);
|
|
84
|
-
|
|
85
|
-
|
|
114
|
+
if (
|
|
115
|
+
declared &&
|
|
116
|
+
(policy.cron !== declared.schedule || policy.keep !== declared.keep)
|
|
117
|
+
)
|
|
86
118
|
return backupCheck(
|
|
87
|
-
'
|
|
119
|
+
'fail',
|
|
120
|
+
'BESZEL_BACKUP_NOT_CONVERGED',
|
|
121
|
+
`The hub's backup schedule is not the one the fleet configuration declares. ${SETTINGS}`
|
|
122
|
+
);
|
|
123
|
+
const age = newest === null ? null : now - newest;
|
|
124
|
+
if (age === null || age > BACKUP_MAX_AGE_MS)
|
|
125
|
+
return backupCheck(
|
|
126
|
+
staleStatus(age, declared !== undefined),
|
|
88
127
|
'BESZEL_BACKUP_STALE',
|
|
89
|
-
`No backup newer than ${BACKUP_MAX_AGE_MS / 3_600_000} hours is listed although a schedule is set.
|
|
128
|
+
`No backup newer than ${BACKUP_MAX_AGE_MS / 3_600_000} hours is listed although a schedule is set. Check the storage's credentials and that the hub can reach it, then create a backup by hand (the hub's /_/ page, Settings > Backups). ${SETTINGS}`
|
|
90
129
|
);
|
|
91
130
|
return backupCheck(
|
|
92
131
|
'pass',
|
|
93
|
-
`Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(
|
|
132
|
+
`Scheduled backups go to off-host storage, keeping ${policy.keep}; the newest is from ${new Date(now - age).toISOString()}.`
|
|
94
133
|
);
|
|
95
134
|
}
|
|
96
135
|
|
|
136
|
+
/** A fresh hub has no backup yet; declared backups that stopped reaching
|
|
137
|
+
* storage fail once the grace period is over.
|
|
138
|
+
*/
|
|
139
|
+
function staleStatus(age: number | null, declared: boolean): Check['status'] {
|
|
140
|
+
return declared && age !== null && age > BACKUP_GRACE_MS ? 'fail' : 'warn';
|
|
141
|
+
}
|
|
142
|
+
|
|
97
143
|
function backupPolicy(
|
|
98
144
|
settings: unknown
|
|
99
145
|
): { cron: string; keep: number; offHost: boolean } | null {
|
|
@@ -8,6 +8,7 @@ import { publicViewerIdentity, fleetAccount } from './hub-access';
|
|
|
8
8
|
import { ensurePublicViewer, type HubLogin } from './hub-viewer';
|
|
9
9
|
import { verifyHubBackups } from './hub-backup';
|
|
10
10
|
import type { FleetDefinition } from '../definition';
|
|
11
|
+
import { secretVia } from '../fleet-config';
|
|
11
12
|
import { hostId, hubUrl } from '../topology';
|
|
12
13
|
import { publicHubUrl, privateHub } from './beszel';
|
|
13
14
|
import type { Check, Runner } from '../types';
|
|
@@ -56,6 +57,9 @@ export interface ManagerHubOptions {
|
|
|
56
57
|
receipts?: string;
|
|
57
58
|
/** Ensures the read-only AUTO_LOGIN user; defaults to the loopback hub API. */
|
|
58
59
|
viewer?: (login: HubLogin | null) => Promise<Check>;
|
|
60
|
+
/** The declared backups for the hub's playbook; defaults to the fleet
|
|
61
|
+
* configuration and its secrets. */
|
|
62
|
+
backupStorage?: () => Promise<Record<string, string | number | boolean>>;
|
|
59
63
|
/** Reads the hub's backup settings; defaults to the loopback hub API. */
|
|
60
64
|
backups?: (login: HubLogin | null) => Promise<Check>;
|
|
61
65
|
}
|
|
@@ -78,13 +82,19 @@ export async function convergeManagerHub(
|
|
|
78
82
|
const paths = managerHubPaths(options.home);
|
|
79
83
|
const tunnel = await tunnelToken(run);
|
|
80
84
|
const account = await (options.account ?? fleetAccount)();
|
|
85
|
+
const backups = await (
|
|
86
|
+
options.backupStorage ?? (() => backupVariables(definition, run))
|
|
87
|
+
)();
|
|
81
88
|
const monitoring = resolve(root, 'ansible/monitoring');
|
|
82
89
|
const result = await runAnsibleWithReceipt(
|
|
83
90
|
run,
|
|
84
91
|
'hub-darwin.yml',
|
|
85
92
|
{
|
|
86
93
|
cwd: monitoring,
|
|
87
|
-
input: JSON.stringify(
|
|
94
|
+
input: JSON.stringify({
|
|
95
|
+
...hubVariables(hub, paths, tunnel, account),
|
|
96
|
+
...backups,
|
|
97
|
+
}),
|
|
88
98
|
timeoutMs: 300_000,
|
|
89
99
|
},
|
|
90
100
|
{
|
|
@@ -127,7 +137,13 @@ export async function convergeManagerHub(
|
|
|
127
137
|
},
|
|
128
138
|
publicEndpointCheck(tunnel !== null),
|
|
129
139
|
await ensureViewer(options, account),
|
|
130
|
-
await (
|
|
140
|
+
await (
|
|
141
|
+
options.backups ??
|
|
142
|
+
((login) =>
|
|
143
|
+
verifyHubBackups(login, fetch, LOCAL_HUB, {
|
|
144
|
+
declared: definition.services.monitoring.backups,
|
|
145
|
+
}))
|
|
146
|
+
)(account),
|
|
131
147
|
];
|
|
132
148
|
}
|
|
133
149
|
|
|
@@ -153,6 +169,32 @@ function hubVariables(
|
|
|
153
169
|
};
|
|
154
170
|
}
|
|
155
171
|
|
|
172
|
+
/** The declared backup schedule and storage, with the storage's keys from
|
|
173
|
+
* the fleet's secrets; nothing when no backups are declared or a key is
|
|
174
|
+
* missing (beszel:backup then says so).
|
|
175
|
+
*/
|
|
176
|
+
export async function backupVariables(
|
|
177
|
+
definition: FleetDefinition,
|
|
178
|
+
run: Runner
|
|
179
|
+
): Promise<Record<string, string | number | boolean>> {
|
|
180
|
+
const declared = definition.services.monitoring.backups;
|
|
181
|
+
if (!declared) return {};
|
|
182
|
+
const secret = secretVia(run);
|
|
183
|
+
const accessKey = await secret('monitoring-backup-access-key');
|
|
184
|
+
const secretKey = await secret('monitoring-backup-secret-key');
|
|
185
|
+
if (!accessKey || !secretKey) return {};
|
|
186
|
+
return {
|
|
187
|
+
beszel_backup_schedule: declared.schedule,
|
|
188
|
+
beszel_backup_keep: declared.keep,
|
|
189
|
+
beszel_backup_bucket: declared.storage.bucket,
|
|
190
|
+
beszel_backup_endpoint: declared.storage.endpoint,
|
|
191
|
+
beszel_backup_region: declared.storage.region,
|
|
192
|
+
beszel_backup_force_path_style: declared.storage.forcePathStyle ?? false,
|
|
193
|
+
beszel_backup_access_key: accessKey,
|
|
194
|
+
beszel_backup_secret_key: secretKey,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
function ensureViewer(
|
|
157
199
|
options: ManagerHubOptions,
|
|
158
200
|
account: HubLogin | null
|