@crvouga/dev-fleet 0.3.0 → 0.5.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 +25 -17
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +12 -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/platforms.json +111 -0
- package/ansible/ubuntu/authorize-sudo.sh +7 -16
- package/ansible/ubuntu/baseline.yml +6 -28
- package/ansible/ubuntu/disk-gc.py +211 -7
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +68 -4
- package/ansible/ubuntu/t3-desktop.yml +2 -2
- package/ansible/ubuntu/tailscale.yml +20 -9
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/dpkg.yml +46 -0
- package/ansible/ubuntu/tasks/install.yml +38 -0
- package/ansible/ubuntu/tasks/packages.yml +4 -55
- package/ansible/ubuntu/tasks/release.yml +25 -0
- package/ansible/ubuntu/tasks/resources.yml +7 -6
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/ansible/ubuntu/tasks/snapshot.yml +50 -0
- package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +49 -45
- 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/consumer-setup.md +32 -30
- package/docs/declared.md +6 -5
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +377 -0
- package/docs/state.md +13 -7
- package/docs/support-matrix.md +36 -0
- package/docs/ubuntu-sync.md +16 -13
- package/docs/worker-contract.md +28 -1
- 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 +526 -0
- package/schema/fleet.schema.json +185 -4
- 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 +4 -2
- 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 +160 -12
- package/src/git-identity.ts +160 -0
- package/src/machine/platform.ts +48 -28
- package/src/machine/support-matrix.ts +76 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +27 -9
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/network-definition.ts +1 -1
- package/src/node-converge-linux.ts +16 -5
- package/src/node-converge.ts +1 -1
- package/src/notify.ts +239 -0
- package/src/platform/debian-version.ts +62 -0
- package/src/platform/ubuntu-audit.ts +11 -4
- package/src/platform/ubuntu-release.ts +41 -0
- package/src/remote-bootstrap.ts +89 -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 +68 -10
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Debian version ordering, as `dpkg --compare-versions` applies it:
|
|
2
|
+
* epoch, then upstream version, then revision. Negative when `a` sorts
|
|
3
|
+
* before `b`, zero when they are the same version, positive after. */
|
|
4
|
+
export function compareDebianVersions(a: string, b: string): number {
|
|
5
|
+
const [epochA, upstreamA, revisionA] = split(a);
|
|
6
|
+
const [epochB, upstreamB, revisionB] = split(b);
|
|
7
|
+
return (
|
|
8
|
+
epochA - epochB ||
|
|
9
|
+
comparePart(upstreamA, upstreamB) ||
|
|
10
|
+
comparePart(revisionA, revisionB)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function split(version: string): [number, string, string] {
|
|
15
|
+
const colon = version.indexOf(':');
|
|
16
|
+
const epoch = colon > 0 ? Number(version.slice(0, colon)) || 0 : 0;
|
|
17
|
+
const rest = colon > 0 ? version.slice(colon + 1) : version;
|
|
18
|
+
const dash = rest.lastIndexOf('-');
|
|
19
|
+
return dash > 0
|
|
20
|
+
? [epoch, rest.slice(0, dash), rest.slice(dash + 1)]
|
|
21
|
+
: [epoch, rest, '0'];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `~` sorts before everything, even the end of the string; letters sort
|
|
25
|
+
* before every other character. */
|
|
26
|
+
function order(char: string | undefined): number {
|
|
27
|
+
if (char === undefined) return 0;
|
|
28
|
+
if (char === '~') return -1;
|
|
29
|
+
if (/[A-Za-z]/.test(char)) return char.charCodeAt(0);
|
|
30
|
+
return char.charCodeAt(0) + 256;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Alternating runs: text, then digits, then text again, starting with text. */
|
|
34
|
+
function runs(part: string): string[] {
|
|
35
|
+
return part
|
|
36
|
+
.match(/\D*\d*/g)!
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.flatMap((run) => {
|
|
39
|
+
const digits = /\d*$/.exec(run)![0];
|
|
40
|
+
return [run.slice(0, run.length - digits.length), digits];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareText(a: string, b: string): number {
|
|
45
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
46
|
+
const difference = order(a[i]) - order(b[i]);
|
|
47
|
+
if (difference) return difference;
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function comparePart(a: string, b: string): number {
|
|
53
|
+
const left = runs(a);
|
|
54
|
+
const right = runs(b);
|
|
55
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 2) {
|
|
56
|
+
const difference =
|
|
57
|
+
compareText(left[i] ?? '', right[i] ?? '') ||
|
|
58
|
+
Number(left[i + 1] || 0) - Number(right[i + 1] || 0);
|
|
59
|
+
if (difference) return difference;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import policy from '../../ansible/ubuntu/policy.json';
|
|
2
1
|
import type { Runner } from '../types';
|
|
2
|
+
import { packagePins, UBUNTU_RELEASES, ubuntuRelease } from './ubuntu-release';
|
|
3
3
|
|
|
4
4
|
export interface UbuntuAudit {
|
|
5
5
|
supported: boolean;
|
|
@@ -11,7 +11,13 @@ export interface UbuntuAudit {
|
|
|
11
11
|
passwordlessSudo: boolean;
|
|
12
12
|
rebootPending: boolean;
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
// Every package any accepted release pins, so one audit serves them all.
|
|
15
|
+
const PACKAGES = [
|
|
16
|
+
...new Set([
|
|
17
|
+
...UBUNTU_RELEASES.flatMap((release) => Object.keys(packagePins(release))),
|
|
18
|
+
'tailscale',
|
|
19
|
+
]),
|
|
20
|
+
];
|
|
15
21
|
|
|
16
22
|
/** Read-only audit: never emit package sources, environment, accounts or raw errors. */
|
|
17
23
|
export async function auditUbuntu(run: Runner): Promise<UbuntuAudit> {
|
|
@@ -32,14 +38,15 @@ export async function auditUbuntu(run: Runner): Promise<UbuntuAudit> {
|
|
|
32
38
|
const version =
|
|
33
39
|
/^VERSION_ID="?([0-9]+\.[0-9]+)"?$/m.exec(release.stdout)?.[1] ?? null;
|
|
34
40
|
const reported = arch.stdout.trim();
|
|
35
|
-
const
|
|
41
|
+
const accepted = ubuntuRelease(version);
|
|
42
|
+
const architecture = accepted?.architectures.includes(reported)
|
|
36
43
|
? reported
|
|
37
44
|
: null;
|
|
38
45
|
return {
|
|
39
46
|
supported:
|
|
40
47
|
release.ok &&
|
|
41
48
|
/^ID=ubuntu$/m.test(release.stdout) &&
|
|
42
|
-
|
|
49
|
+
accepted !== undefined &&
|
|
43
50
|
architecture !== null,
|
|
44
51
|
release: version,
|
|
45
52
|
architecture,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import policy from '../../ansible/ubuntu/policy.json';
|
|
2
|
+
|
|
3
|
+
/** One Ubuntu release workers may run. The accepted releases are named once,
|
|
4
|
+
* in ansible/ubuntu/policy.json; nothing else spells a release or codename. */
|
|
5
|
+
export interface UbuntuRelease {
|
|
6
|
+
release: string;
|
|
7
|
+
codename: string;
|
|
8
|
+
/** Debian architecture names, as `dpkg --print-architecture` reports them. */
|
|
9
|
+
architectures: string[];
|
|
10
|
+
/** Pins of this release that differ from the policy's `packages`. */
|
|
11
|
+
packages?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const UBUNTU_RELEASES: readonly UbuntuRelease[] = policy.releases;
|
|
15
|
+
|
|
16
|
+
/** The package pins of one release: the policy's, with the release's own
|
|
17
|
+
* pins over them (as tasks/release.yml combines them for Ansible). */
|
|
18
|
+
export function packagePins(
|
|
19
|
+
release: UbuntuRelease | undefined
|
|
20
|
+
): Record<string, string> {
|
|
21
|
+
return { ...policy.packages, ...release?.packages };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `uname -m` for each Debian architecture name. */
|
|
25
|
+
const KERNEL_ARCHITECTURES: Record<string, string> = {
|
|
26
|
+
amd64: 'x86_64',
|
|
27
|
+
arm64: 'aarch64',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function ubuntuRelease(
|
|
31
|
+
version: string | null | undefined
|
|
32
|
+
): UbuntuRelease | undefined {
|
|
33
|
+
return UBUNTU_RELEASES.find((entry) => entry.release === version);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `uname -m` names one release runs on. */
|
|
37
|
+
export function kernelArchitectures(release: UbuntuRelease): string[] {
|
|
38
|
+
return release.architectures.map(
|
|
39
|
+
(name) => KERNEL_ARCHITECTURES[name] ?? name
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/remote-bootstrap.ts
CHANGED
|
@@ -4,10 +4,26 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { prepareSource } from './machine/source';
|
|
5
5
|
import { isRevision } from './machine/state';
|
|
6
6
|
import { sshArgs, shellQuote, sshFailure } from './ssh';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
FleetNode,
|
|
9
|
+
NodeReport,
|
|
10
|
+
Runner,
|
|
11
|
+
Check,
|
|
12
|
+
CommandResult,
|
|
13
|
+
} from './types';
|
|
14
|
+
import {
|
|
15
|
+
followOperation,
|
|
16
|
+
realClock,
|
|
17
|
+
retrying,
|
|
18
|
+
RETRY_PAUSES_MS,
|
|
19
|
+
transient,
|
|
20
|
+
type Clock,
|
|
21
|
+
type Follow,
|
|
22
|
+
} from './remote-operation';
|
|
8
23
|
import { record } from './config';
|
|
9
24
|
import { installedDefinition, type FleetDefinition } from './definition';
|
|
10
25
|
import { cleanCheck, validCheck } from './protocol';
|
|
26
|
+
import { UBUNTU_RELEASES } from './platform/ubuntu-release';
|
|
11
27
|
|
|
12
28
|
export interface BootstrapPayload {
|
|
13
29
|
revision: string;
|
|
@@ -94,9 +110,11 @@ async function bundleRevision(
|
|
|
94
110
|
export async function bootstrapRemote(
|
|
95
111
|
node: FleetNode,
|
|
96
112
|
payload: BootstrapPayload,
|
|
97
|
-
run: Runner
|
|
113
|
+
run: Runner,
|
|
114
|
+
follow: Follow = {}
|
|
98
115
|
): Promise<NodeReport> {
|
|
99
|
-
const
|
|
116
|
+
const clock = follow.clock ?? realClock;
|
|
117
|
+
const cached = await cachedRevision(node, payload.revision, run, clock);
|
|
100
118
|
if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
|
|
101
119
|
const transfer = cached
|
|
102
120
|
? { mode: 'cached', bundle: '' }
|
|
@@ -110,30 +128,72 @@ export async function bootstrapRemote(
|
|
|
110
128
|
payload.digest,
|
|
111
129
|
node.id,
|
|
112
130
|
transfer.mode,
|
|
131
|
+
UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
|
|
113
132
|
]
|
|
114
133
|
.map(shellQuote)
|
|
115
134
|
.join(' ');
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
const checks = await bootstrapSessions(
|
|
136
|
+
node,
|
|
137
|
+
payload,
|
|
138
|
+
{ command, input: bootstrapInput(payload.config, transfer.bundle) },
|
|
139
|
+
{ run, follow: { ...follow, clock } }
|
|
140
|
+
);
|
|
141
|
+
return { node: node.id, checks };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Opens the bootstrap session, and again after a lost one unless the
|
|
145
|
+
* worker's own operation can be followed to its end instead.
|
|
146
|
+
*/
|
|
147
|
+
async function bootstrapSessions(
|
|
148
|
+
node: FleetNode,
|
|
149
|
+
payload: BootstrapPayload,
|
|
150
|
+
session: { command: string; input: string },
|
|
151
|
+
context: { run: Runner; follow: Follow & { clock: Clock } }
|
|
152
|
+
): Promise<Check[]> {
|
|
153
|
+
const { run, follow } = context;
|
|
154
|
+
let result: CommandResult | undefined;
|
|
155
|
+
for (const pause of [0, ...RETRY_PAUSES_MS]) {
|
|
156
|
+
if (result) {
|
|
157
|
+
// The session was lost, not the operation: the worker keeps running
|
|
158
|
+
// it, so the manager follows it rather than starting another.
|
|
159
|
+
await follow.clock.pause(pause);
|
|
160
|
+
const followed = await followOperation(
|
|
161
|
+
node,
|
|
162
|
+
payload.revision,
|
|
163
|
+
run,
|
|
164
|
+
follow
|
|
165
|
+
);
|
|
166
|
+
if (followed) return followed;
|
|
167
|
+
}
|
|
168
|
+
result = await run([...sshArgs(node), session.command], {
|
|
169
|
+
input: session.input,
|
|
170
|
+
timeoutMs: 5_400_000,
|
|
171
|
+
});
|
|
172
|
+
const evidence = parseEvidence(result);
|
|
173
|
+
if (evidence && locked(evidence))
|
|
174
|
+
return (
|
|
175
|
+
(await followOperation(node, payload.revision, run, follow)) ?? evidence
|
|
176
|
+
);
|
|
177
|
+
if (evidence) return evidence;
|
|
178
|
+
if (result.reason !== 'exit' || !transient(result)) break;
|
|
179
|
+
}
|
|
180
|
+
if (!result!.ok) return [sshFailure(result!)];
|
|
181
|
+
return [
|
|
182
|
+
{
|
|
183
|
+
id: 'bootstrap:protocol',
|
|
184
|
+
status: 'fail',
|
|
185
|
+
summary: 'REMOTE_BOOTSTRAP_EVIDENCE_INVALID',
|
|
186
|
+
remedy: {
|
|
187
|
+
action:
|
|
188
|
+
'Inspect the source-owned remote operation and lock. Do not assume a manager disconnect stopped installation.',
|
|
134
189
|
},
|
|
135
|
-
|
|
136
|
-
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Another operation holds the worker: its lock was the first thing tried. */
|
|
195
|
+
function locked(checks: Check[]): boolean {
|
|
196
|
+
return checks.some((check) => check.summary === 'FLEET_REMOTE_LOCKED');
|
|
137
197
|
}
|
|
138
198
|
|
|
139
199
|
/** What the bootstrap reads on stdin: one line with the fleet configuration
|
|
@@ -151,10 +211,14 @@ export function bootstrapInput(
|
|
|
151
211
|
async function cachedRevision(
|
|
152
212
|
node: FleetNode,
|
|
153
213
|
revision: string,
|
|
154
|
-
run: Runner
|
|
214
|
+
run: Runner,
|
|
215
|
+
clock: Clock
|
|
155
216
|
): Promise<boolean | Check> {
|
|
156
217
|
const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
|
|
157
|
-
const result = await
|
|
218
|
+
const result = await retrying(
|
|
219
|
+
() => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
|
|
220
|
+
clock
|
|
221
|
+
);
|
|
158
222
|
if (!result.ok) {
|
|
159
223
|
const failure = sshFailure(result);
|
|
160
224
|
if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import { cleanCheck, validCheck } from './protocol';
|
|
3
|
+
import { sshArgs, sshFailure } from './ssh';
|
|
4
|
+
import type { Check, CommandResult, FleetNode, Runner } from './types';
|
|
5
|
+
|
|
6
|
+
/** How the manager waits: a pause, and the time now. Tests answer both. */
|
|
7
|
+
export interface Clock {
|
|
8
|
+
pause(ms: number): Promise<void>;
|
|
9
|
+
now(): number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const realClock: Clock = {
|
|
13
|
+
pause: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
14
|
+
now: () => Date.now(),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A session that could not be opened, or was lost, is tried again after
|
|
18
|
+
* these pauses: three attempts over thirty seconds.
|
|
19
|
+
*/
|
|
20
|
+
export const RETRY_PAUSES_MS = [10_000, 20_000];
|
|
21
|
+
/** How often an operation the manager follows is looked at again. */
|
|
22
|
+
export const FOLLOW_EVERY_MS = 15_000;
|
|
23
|
+
/** As long as a bootstrap may run. */
|
|
24
|
+
export const FOLLOW_AT_MOST_MS = 5_400_000;
|
|
25
|
+
|
|
26
|
+
/** Failures of the connection, not of the worker: worth another attempt. A
|
|
27
|
+
* changed host key, a refused login or an unknown name never are.
|
|
28
|
+
*/
|
|
29
|
+
export function transient(result: CommandResult): boolean {
|
|
30
|
+
if (result.ok) return false;
|
|
31
|
+
const { id } = sshFailure(result);
|
|
32
|
+
return ['ssh:disconnected', 'ssh:network', 'ssh:timeout'].includes(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Runs a short SSH session, again after a pause while its connection fails. */
|
|
36
|
+
export async function retrying(
|
|
37
|
+
attempt: () => Promise<CommandResult>,
|
|
38
|
+
clock: Clock
|
|
39
|
+
): Promise<CommandResult> {
|
|
40
|
+
let result = await attempt();
|
|
41
|
+
for (const pause of RETRY_PAUSES_MS) {
|
|
42
|
+
if (!transient(result)) return result;
|
|
43
|
+
await clock.pause(pause);
|
|
44
|
+
result = await attempt();
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a worker says about the fleet operation on it. */
|
|
50
|
+
export interface Operation {
|
|
51
|
+
/** The operation lock is held by a running process. */
|
|
52
|
+
held: boolean;
|
|
53
|
+
/** The process that recorded the operation is still running. */
|
|
54
|
+
alive: boolean;
|
|
55
|
+
revision: string | null;
|
|
56
|
+
phase: string | null;
|
|
57
|
+
/** Seconds since the epoch, on the worker's clock. */
|
|
58
|
+
started: number | null;
|
|
59
|
+
finished: boolean;
|
|
60
|
+
/** What node convergence reported, once the operation finished. */
|
|
61
|
+
result: Check[] | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read-only. Prints whether the lock is held, whether the recorded process
|
|
65
|
+
* runs, the operation record and its result, one per line. Holding the lock
|
|
66
|
+
* for a moment to test it is not possible while another holds it, and takes
|
|
67
|
+
* nothing from anyone when nobody does.
|
|
68
|
+
*/
|
|
69
|
+
export const OPERATION_PROBE = [
|
|
70
|
+
'r="$HOME/.local/share/personal/fleet"',
|
|
71
|
+
'l="$r/remote-operation.lock"',
|
|
72
|
+
'held=no',
|
|
73
|
+
'if [ -f "$l" ] && [ ! -L "$l" ]; then if command -v flock >/dev/null 2>&1; then flock -n "$l" true || held=yes; else python3 -c \'import fcntl, sys; fcntl.flock(open(sys.argv[1], "a"), fcntl.LOCK_EX | fcntl.LOCK_NB)\' "$l" 2>/dev/null || held=yes; fi; fi',
|
|
74
|
+
'op=$(head -c 4096 "$r/remote-operation.json" 2>/dev/null | head -n 1)',
|
|
75
|
+
'pid=$(printf %s "$op" | sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\')',
|
|
76
|
+
'alive=no',
|
|
77
|
+
'if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=yes; fi',
|
|
78
|
+
'printf \'held=%s\\nalive=%s\\noperation=%s\\nresult=\' "$held" "$alive" "$op"',
|
|
79
|
+
'head -c 1048576 "$r/remote-operation-result.json" 2>/dev/null | head -n 1',
|
|
80
|
+
"printf '\\n'",
|
|
81
|
+
].join('\n');
|
|
82
|
+
|
|
83
|
+
function field(stdout: string, name: string): string | null {
|
|
84
|
+
const line = stdout.split('\n').find((entry) => entry.startsWith(`${name}=`));
|
|
85
|
+
return line === undefined ? null : line.slice(name.length + 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function json(text: string | null): unknown {
|
|
89
|
+
try {
|
|
90
|
+
return text ? JSON.parse(text) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function evidence(value: unknown): Check[] | null {
|
|
97
|
+
return record(value) &&
|
|
98
|
+
value['version'] === 1 &&
|
|
99
|
+
Array.isArray(value['checks']) &&
|
|
100
|
+
value['checks'].length > 0 &&
|
|
101
|
+
value['checks'].length <= 200 &&
|
|
102
|
+
value['checks'].every(validCheck)
|
|
103
|
+
? value['checks'].map(cleanCheck)
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseOperation(stdout: string): Operation | null {
|
|
108
|
+
const held = field(stdout, 'held');
|
|
109
|
+
const alive = field(stdout, 'alive');
|
|
110
|
+
if (held === null || alive === null) return null;
|
|
111
|
+
const operation = json(field(stdout, 'operation'));
|
|
112
|
+
const op = record(operation) ? operation : {};
|
|
113
|
+
const text = (value: unknown, pattern: RegExp): string | null =>
|
|
114
|
+
typeof value === 'string' && pattern.test(value) ? value : null;
|
|
115
|
+
return {
|
|
116
|
+
held: held === 'yes',
|
|
117
|
+
alive: alive === 'yes',
|
|
118
|
+
revision: text(op['revision'], /^[a-f0-9]{40}$/),
|
|
119
|
+
phase: text(op['phase'], /^[a-z0-9-]{1,40}$/),
|
|
120
|
+
started: typeof op['started'] === 'number' ? op['started'] : null,
|
|
121
|
+
finished: typeof op['finished'] === 'number',
|
|
122
|
+
result: evidence(json(field(stdout, 'result'))),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function inspectOperation(
|
|
127
|
+
node: FleetNode,
|
|
128
|
+
run: Runner,
|
|
129
|
+
clock: Clock
|
|
130
|
+
): Promise<Operation | null> {
|
|
131
|
+
const result = await retrying(
|
|
132
|
+
() => run([...sshArgs(node), OPERATION_PROBE], { timeoutMs: 20_000 }),
|
|
133
|
+
clock
|
|
134
|
+
);
|
|
135
|
+
return result.ok ? parseOperation(result.stdout) : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** An operation of this revision whose process still runs: this is the run
|
|
139
|
+
* the manager lost, or one another manager run started; either way it is
|
|
140
|
+
* the run to follow, not one to start beside it.
|
|
141
|
+
*/
|
|
142
|
+
function followable(operation: Operation, revision: string): boolean {
|
|
143
|
+
return (
|
|
144
|
+
operation.held &&
|
|
145
|
+
operation.alive &&
|
|
146
|
+
!operation.finished &&
|
|
147
|
+
operation.revision === revision
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finishedWith(operation: Operation, revision: string): Check[] | null {
|
|
152
|
+
return !operation.held &&
|
|
153
|
+
operation.finished &&
|
|
154
|
+
operation.revision === revision
|
|
155
|
+
? operation.result
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface Follow {
|
|
160
|
+
clock?: Clock;
|
|
161
|
+
/** Told the phase and how long the operation has run, at every look. */
|
|
162
|
+
progress?: (phase: string) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** After a lost session or a held lock: the result of the operation of this
|
|
166
|
+
* revision on the worker, followed to its end when it still runs. Null when
|
|
167
|
+
* there is none to follow: another revision, another holder, or nothing.
|
|
168
|
+
*/
|
|
169
|
+
export async function followOperation(
|
|
170
|
+
node: FleetNode,
|
|
171
|
+
revision: string,
|
|
172
|
+
run: Runner,
|
|
173
|
+
options: Follow = {}
|
|
174
|
+
): Promise<Check[] | null> {
|
|
175
|
+
const clock = options.clock ?? realClock;
|
|
176
|
+
const deadline = clock.now() + FOLLOW_AT_MOST_MS;
|
|
177
|
+
let operation = await inspectOperation(node, run, clock);
|
|
178
|
+
if (!operation) return null;
|
|
179
|
+
const done = finishedWith(operation, revision);
|
|
180
|
+
if (done) return [attached(operation, 'finished'), ...done];
|
|
181
|
+
if (!followable(operation, revision)) return null;
|
|
182
|
+
let last: Operation = operation;
|
|
183
|
+
while (clock.now() < deadline) {
|
|
184
|
+
options.progress?.(describe(last, clock));
|
|
185
|
+
await clock.pause(FOLLOW_EVERY_MS);
|
|
186
|
+
operation = await inspectOperation(node, run, clock);
|
|
187
|
+
if (!operation) continue;
|
|
188
|
+
last = operation;
|
|
189
|
+
const result = finishedWith(operation, revision);
|
|
190
|
+
if (result) return [attached(operation, 'followed'), ...result];
|
|
191
|
+
if (!followable(operation, revision)) return null;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function describe(operation: Operation, clock: Clock): string {
|
|
197
|
+
const elapsed =
|
|
198
|
+
operation.started === null
|
|
199
|
+
? ''
|
|
200
|
+
: `, ${Math.max(0, Math.round(clock.now() / 1000 - operation.started))} s`;
|
|
201
|
+
return `Following the running operation (${operation.phase ?? 'unknown phase'}${elapsed})`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function attached(_operation: Operation, how: 'finished' | 'followed'): Check {
|
|
205
|
+
return {
|
|
206
|
+
id: 'bootstrap:attached',
|
|
207
|
+
status: 'pass',
|
|
208
|
+
summary:
|
|
209
|
+
how === 'followed'
|
|
210
|
+
? 'The manager lost or could not take this machine’s operation of the same revision, followed it to its end and reports its result.'
|
|
211
|
+
: 'This machine finished the operation of the same revision the manager lost; its result is reported.',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { History } from './state/history';
|
|
2
|
+
import type { Check } from './types';
|
|
3
|
+
|
|
4
|
+
/** How often the manager's schedule runs fleet-sync. */
|
|
5
|
+
export const SCHEDULE_INTERVAL_SECONDS = 1800;
|
|
6
|
+
|
|
7
|
+
/** `manager:schedule`: a schedule that is on must have succeeded within two
|
|
8
|
+
* of its intervals. A schedule that was never turned on is no check at all.
|
|
9
|
+
*/
|
|
10
|
+
export function scheduleCheck(history: History, now: Date): Check | null {
|
|
11
|
+
const { schedule } = history;
|
|
12
|
+
if (!schedule?.enabled) return null;
|
|
13
|
+
const window = 2 * schedule.intervalSeconds * 1000;
|
|
14
|
+
const lastSuccess = history.runs
|
|
15
|
+
.filter((run) => run.trigger === 'schedule' && run.ok)
|
|
16
|
+
.map((run) => Date.parse(run.finishedAt))
|
|
17
|
+
.reduce((latest, at) => Math.max(latest, at), Number.NEGATIVE_INFINITY);
|
|
18
|
+
// A schedule turned on a moment ago has had no chance to run yet.
|
|
19
|
+
const since = Math.max(lastSuccess, Date.parse(schedule.changedAt));
|
|
20
|
+
const minutes = Math.round(schedule.intervalSeconds / 60);
|
|
21
|
+
if (now.getTime() - since <= window)
|
|
22
|
+
return {
|
|
23
|
+
id: 'manager:schedule',
|
|
24
|
+
status: 'pass',
|
|
25
|
+
summary: Number.isFinite(lastSuccess)
|
|
26
|
+
? `The schedule last converged the fleet at ${new Date(lastSuccess).toISOString()}.`
|
|
27
|
+
: 'The schedule was turned on and has not had to run yet.',
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
id: 'manager:schedule',
|
|
31
|
+
status: 'fail',
|
|
32
|
+
summary: Number.isFinite(lastSuccess)
|
|
33
|
+
? `The schedule has not converged the fleet since ${new Date(lastSuccess).toISOString()}, more than two intervals of ${minutes} minutes.`
|
|
34
|
+
: `The schedule has not converged the fleet once since it was turned on, more than two intervals of ${minutes} minutes ago.`,
|
|
35
|
+
remedy: {
|
|
36
|
+
action:
|
|
37
|
+
'Run devfleet fleet-sync --schedule status on the manager to see the last scheduled runs, then devfleet fleet-report for the failing checks. Re-enable it with devfleet fleet-sync --schedule enable --apply --yes when the manager was replaced.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/secrets.ts
CHANGED
|
@@ -12,7 +12,20 @@ export const SECRET_NAMES = [
|
|
|
12
12
|
'monitoring-tunnel-token',
|
|
13
13
|
'proxy-tunnel-token',
|
|
14
14
|
] as const;
|
|
15
|
-
|
|
15
|
+
/** Secrets only a feature the fleet declares needs: a Vault location may
|
|
16
|
+
* leave them out, and they are then unavailable.
|
|
17
|
+
*/
|
|
18
|
+
export const OPTIONAL_SECRET_NAMES = [
|
|
19
|
+
// The off-machine storage of the monitoring hub's backups
|
|
20
|
+
// (services.monitoring.backups).
|
|
21
|
+
'monitoring-backup-access-key',
|
|
22
|
+
'monitoring-backup-secret-key',
|
|
23
|
+
// Where fleet-watch notifies (notify.targets).
|
|
24
|
+
'notify-webhook-url',
|
|
25
|
+
'notify-ntfy-url',
|
|
26
|
+
] as const;
|
|
27
|
+
export type SecretName =
|
|
28
|
+
(typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
|
|
16
29
|
|
|
17
30
|
/** A secret's location in a Vault KV v2 mount. */
|
|
18
31
|
export type VaultLocation = { path: string; field: string };
|
|
@@ -24,8 +37,13 @@ export type SecretsConfig =
|
|
|
24
37
|
provider: 'vault';
|
|
25
38
|
address: string;
|
|
26
39
|
mount: string;
|
|
27
|
-
locations:
|
|
40
|
+
locations: VaultLocations;
|
|
28
41
|
};
|
|
42
|
+
export type VaultLocations = Record<
|
|
43
|
+
(typeof SECRET_NAMES)[number],
|
|
44
|
+
VaultLocation
|
|
45
|
+
> &
|
|
46
|
+
Partial<Record<(typeof OPTIONAL_SECRET_NAMES)[number], VaultLocation>>;
|
|
29
47
|
|
|
30
48
|
/** Runs a command and returns its stdout, or null when it fails. */
|
|
31
49
|
export type SecretCommand = (
|
|
@@ -79,6 +97,7 @@ export function secretsProvider(
|
|
|
79
97
|
);
|
|
80
98
|
return async (name) => {
|
|
81
99
|
const location = config.locations[name];
|
|
100
|
+
if (!location) return null;
|
|
82
101
|
return present(
|
|
83
102
|
await run([
|
|
84
103
|
'vault',
|
|
@@ -142,13 +161,12 @@ const PARSERS: Record<string, (value: Fields) => SecretsConfig | null> = {
|
|
|
142
161
|
},
|
|
143
162
|
};
|
|
144
163
|
|
|
145
|
-
function vaultLocations(
|
|
146
|
-
value: unknown
|
|
147
|
-
): Record<SecretName, VaultLocation> | null {
|
|
164
|
+
function vaultLocations(value: unknown): VaultLocations | null {
|
|
148
165
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
149
166
|
const fields = value as Fields;
|
|
150
|
-
if (!only(fields, [...SECRET_NAMES])) return null;
|
|
151
|
-
const
|
|
167
|
+
if (!only(fields, [...SECRET_NAMES, ...OPTIONAL_SECRET_NAMES])) return null;
|
|
168
|
+
const optional = OPTIONAL_SECRET_NAMES.filter((name) => name in fields);
|
|
169
|
+
const valid = [...SECRET_NAMES, ...optional].every((name) => {
|
|
152
170
|
const location = fields[name] as Fields | undefined;
|
|
153
171
|
return (
|
|
154
172
|
location !== null &&
|
|
@@ -158,7 +176,7 @@ function vaultLocations(
|
|
|
158
176
|
text(location['field'])
|
|
159
177
|
);
|
|
160
178
|
});
|
|
161
|
-
return valid ? (fields as
|
|
179
|
+
return valid ? (fields as VaultLocations) : null;
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
/** The `secrets` block of a fleet configuration, or null when it is invalid. */
|
package/src/ssh.ts
CHANGED
|
@@ -20,9 +20,9 @@ export function sshArgs(node: FleetNode): string[] {
|
|
|
20
20
|
'-o',
|
|
21
21
|
'ConnectionAttempts=1',
|
|
22
22
|
'-o',
|
|
23
|
-
'ServerAliveInterval=
|
|
23
|
+
'ServerAliveInterval=15',
|
|
24
24
|
'-o',
|
|
25
|
-
'ServerAliveCountMax=
|
|
25
|
+
'ServerAliveCountMax=4',
|
|
26
26
|
'-o',
|
|
27
27
|
'ClearAllForwardings=yes',
|
|
28
28
|
'-o',
|
|
@@ -66,6 +66,14 @@ const FAILURES = [
|
|
|
66
66
|
action:
|
|
67
67
|
'Use ssh-add -l and ssh -vvv ALIAS locally; check User, IdentityFile, IdentitiesOnly, agent/keychain access and authorized_keys. Do not share debug logs without review.',
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
pattern:
|
|
71
|
+
/Connection (?:to \S+ )?closed|Connection reset|Broken pipe|Timeout, server \S+ not responding|client_loop: send disconnect/i,
|
|
72
|
+
id: 'ssh:disconnected',
|
|
73
|
+
summary: 'The SSH session was lost while the operation ran.',
|
|
74
|
+
action:
|
|
75
|
+
'The worker keeps running what it started. Rerun fleet-sync: it follows the operation of the same revision to its end. Check the network between the manager and the worker if it repeats.',
|
|
76
|
+
},
|
|
69
77
|
{
|
|
70
78
|
pattern: /Connection refused|No route to host|Network is unreachable/i,
|
|
71
79
|
id: 'ssh:network',
|