@crvouga/dev-fleet 0.3.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 +22 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +11 -1
- 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 +50 -1
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +1 -1
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/checkpoint.py +1 -1
- package/docs/authentication.md +90 -0
- package/docs/declared.md +2 -0
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/ubuntu-sync.md +7 -6
- package/examples/fleet.json +19 -1
- package/llms.txt +2 -0
- package/mise.toml +7 -0
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +178 -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/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-manager.ts +11 -0
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +3 -1
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +90 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +51 -0
- package/src/cli/registry.ts +13 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +142 -5
- package/src/git-identity.ts +160 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +12 -2
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +16 -5
- package/src/notify.ts +239 -0
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +32 -0
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runAnsible } from './platform/ansible';
|
|
4
|
+
import type { GitIdentity } from './definition';
|
|
5
|
+
import type { Check, Runner } from './types';
|
|
6
|
+
|
|
7
|
+
/** `ansible/github/git.yml` writes the declared identity and the GitHub
|
|
8
|
+
* credential helper in the node's own Git configuration; the two checks
|
|
9
|
+
* below measure them afterwards. The same on the manager and every worker.
|
|
10
|
+
*/
|
|
11
|
+
export async function convergeGitIdentity(
|
|
12
|
+
root: string,
|
|
13
|
+
git: GitIdentity | undefined,
|
|
14
|
+
run: Runner,
|
|
15
|
+
home: string = homedir()
|
|
16
|
+
): Promise<Check[]> {
|
|
17
|
+
if (!git) return [];
|
|
18
|
+
const result = await runAnsible(
|
|
19
|
+
run,
|
|
20
|
+
resolve(root, 'ansible/github/git.yml'),
|
|
21
|
+
{
|
|
22
|
+
cwd: resolve(root, 'ansible/github'),
|
|
23
|
+
timeoutMs: 120_000,
|
|
24
|
+
input: JSON.stringify({
|
|
25
|
+
git_name: git.name,
|
|
26
|
+
git_email: git.email,
|
|
27
|
+
git_home: home,
|
|
28
|
+
}),
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
id: 'git:configuration',
|
|
34
|
+
status: result.ok ? 'pass' : 'fail',
|
|
35
|
+
summary: result.ok
|
|
36
|
+
? 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
|
|
37
|
+
: 'GIT_CONFIGURATION_INCOMPLETE',
|
|
38
|
+
...(result.ok
|
|
39
|
+
? {}
|
|
40
|
+
: {
|
|
41
|
+
remedy: {
|
|
42
|
+
action:
|
|
43
|
+
'Inspect ~/.config/git/config and ~/.gitconfig on this node: a ~/.gitconfig that sets user.name, user.email or a GitHub credential helper wins over the fleet. Remove those lines and rerun fleet-sync.',
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Git with no prompt: a missing credential fails instead of waiting. */
|
|
51
|
+
const QUIET = {
|
|
52
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
53
|
+
GCM_INTERACTIVE: 'never',
|
|
54
|
+
GIT_ASKPASS: '',
|
|
55
|
+
SSH_ASKPASS: '',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function effective(run: Runner, key: string): Promise<string> {
|
|
59
|
+
const result = await run(['git', 'config', '--global', '--get-all', key], {
|
|
60
|
+
timeoutMs: 10_000,
|
|
61
|
+
});
|
|
62
|
+
return result.ok ? (result.stdout.trim().split('\n').pop() ?? '') : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Commits carry the declared author: the effective `user.name` and
|
|
66
|
+
* `user.email` outside any repository are the fleet's.
|
|
67
|
+
*/
|
|
68
|
+
export async function gitIdentityCheck(
|
|
69
|
+
git: GitIdentity | undefined,
|
|
70
|
+
run: Runner
|
|
71
|
+
): Promise<Check> {
|
|
72
|
+
const id = 'worker:git-identity';
|
|
73
|
+
if (!git)
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
status: 'warn',
|
|
77
|
+
summary: 'GIT_IDENTITY_UNDECLARED',
|
|
78
|
+
remedy: {
|
|
79
|
+
action:
|
|
80
|
+
'Declare git.name and git.email in fleet.json and rerun fleet-sync; until then commits on this node carry whatever author it happens to have.',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const name = await effective(run, 'user.name');
|
|
84
|
+
const email = await effective(run, 'user.email');
|
|
85
|
+
if (name === git.name && email === git.email)
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
status: 'pass',
|
|
89
|
+
summary: `Commits on this node are authored as ${git.name} <${git.email}>.`,
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
status: 'fail',
|
|
94
|
+
summary: !name || !email ? 'GIT_IDENTITY_MISSING' : 'GIT_IDENTITY_MISMATCH',
|
|
95
|
+
remedy: {
|
|
96
|
+
action:
|
|
97
|
+
'Rerun fleet-sync: it writes the identity in ~/.config/git/config. A ~/.gitconfig that sets another user.name or user.email wins over it; remove those lines.',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A push is authorized through git itself, never through gh: Git's
|
|
103
|
+
* credential helper must answer for github.com, and the declared repository
|
|
104
|
+
* must answer an authenticated listing. The credential is read and dropped;
|
|
105
|
+
* it is never kept, printed or reported.
|
|
106
|
+
*/
|
|
107
|
+
export async function gitPushAuthCheck(
|
|
108
|
+
git: GitIdentity | undefined,
|
|
109
|
+
run: Runner
|
|
110
|
+
): Promise<Check> {
|
|
111
|
+
const id = 'worker:git-push-auth';
|
|
112
|
+
const fill = await run(['git', 'credential', 'fill'], {
|
|
113
|
+
input: 'protocol=https\nhost=github.com\n\n',
|
|
114
|
+
env: QUIET,
|
|
115
|
+
timeoutMs: 20_000,
|
|
116
|
+
});
|
|
117
|
+
if (!fill.ok || !/^password=./m.test(fill.stdout))
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
status: 'fail',
|
|
121
|
+
summary: 'GIT_CREDENTIAL_HELPER_MISSING',
|
|
122
|
+
remedy: {
|
|
123
|
+
action:
|
|
124
|
+
'Git has no credential for github.com, so an agent cannot push over HTTPS. Rerun fleet-sync, which declares gh as the credential helper; if gh is signed out, the worker signs in again from its own Vault session (auth:github).',
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
if (git?.verifyRepository) {
|
|
128
|
+
const listed = await run(
|
|
129
|
+
['git', 'ls-remote', '--heads', git.verifyRepository],
|
|
130
|
+
{ env: QUIET, timeoutMs: 30_000 }
|
|
131
|
+
);
|
|
132
|
+
if (!listed.ok)
|
|
133
|
+
return {
|
|
134
|
+
id,
|
|
135
|
+
status: 'fail',
|
|
136
|
+
summary:
|
|
137
|
+
listed.reason === 'timeout'
|
|
138
|
+
? 'GIT_REMOTE_UNREACHABLE'
|
|
139
|
+
: 'GIT_REMOTE_REFUSED',
|
|
140
|
+
remedy: {
|
|
141
|
+
action:
|
|
142
|
+
"The declared repository (git.verifyRepository) did not answer git with this node's credentials. Check that the fleet's GitHub account can push to it and that github.com is reachable.",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
id,
|
|
148
|
+
status: 'pass',
|
|
149
|
+
summary: git?.verifyRepository
|
|
150
|
+
? "Git's credential helper answers for github.com and the declared repository answers through git."
|
|
151
|
+
: "Git's credential helper answers for github.com; declare git.verifyRepository to prove a repository too.",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function gitChecks(
|
|
156
|
+
git: GitIdentity | undefined,
|
|
157
|
+
run: Runner
|
|
158
|
+
): Promise<Check[]> {
|
|
159
|
+
return [await gitIdentityCheck(git, run), await gitPushAuthCheck(git, run)];
|
|
160
|
+
}
|
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({
|
|
@@ -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 {
|