@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
|
@@ -6,15 +6,24 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
|
|
|
6
6
|
import { verifyResourceSafety } from './platform/resource-verify';
|
|
7
7
|
import { convergeT3Route, T3_ROUTE } from './platform/t3-route';
|
|
8
8
|
import { convergeProxy } from './proxy-converge';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { IDES } from './ide';
|
|
10
|
+
import { enabled, settingsChecks } from './ide/orchestrate';
|
|
11
|
+
import { idesOf } from './ide/definition';
|
|
12
|
+
import { fleet } from './fleet-config';
|
|
11
13
|
import { quotaLimited, verifyFleetProviders } from './agents/fleet-verify';
|
|
12
14
|
import { verifyNativeAgents } from './agents/native-verify';
|
|
15
|
+
import { accountContext, nodeAccountChecks } from './accounts/context';
|
|
13
16
|
import { managedT3, managedT3Desktop } from '../bootstrap/native-t3';
|
|
14
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
convergeT3Service,
|
|
19
|
+
runningVersionCheck,
|
|
20
|
+
t3StartedAt,
|
|
21
|
+
} from './platform/t3-service';
|
|
15
22
|
import { userInfo } from 'node:os';
|
|
16
23
|
import { resolve } from 'node:path';
|
|
17
24
|
import { auditUbuntu } from './platform/ubuntu-audit';
|
|
25
|
+
import { convergeGitIdentity } from './git-identity';
|
|
26
|
+
import { workerSelection } from '../bootstrap/worker-tools';
|
|
18
27
|
import {
|
|
19
28
|
applyAgentProfiles,
|
|
20
29
|
attempt,
|
|
@@ -52,7 +61,7 @@ export async function convergeLinux(
|
|
|
52
61
|
await applyTailscale(root, run),
|
|
53
62
|
]))
|
|
54
63
|
);
|
|
55
|
-
const { baseline, verified } = await baselineStep(root, run);
|
|
64
|
+
const { baseline, verified } = await baselineStep(root, run, node);
|
|
56
65
|
checks.push(...baseline, ...verified);
|
|
57
66
|
checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
|
|
58
67
|
checks.push(
|
|
@@ -60,6 +69,11 @@ export async function convergeLinux(
|
|
|
60
69
|
configureWorkstation(root, run)
|
|
61
70
|
))
|
|
62
71
|
);
|
|
72
|
+
checks.push(
|
|
73
|
+
...(await attempt('git:configuration', () =>
|
|
74
|
+
convergeGitIdentity(root, fleet.git, run)
|
|
75
|
+
))
|
|
76
|
+
);
|
|
63
77
|
checks.push(
|
|
64
78
|
...(await attempt('configuration:apply', () =>
|
|
65
79
|
applyAgentProfiles(root, node, revision, run)
|
|
@@ -74,12 +88,19 @@ export async function convergeLinux(
|
|
|
74
88
|
verifyFleetProviders(node)
|
|
75
89
|
);
|
|
76
90
|
checks.push(...providers);
|
|
91
|
+
const accounts = await accountContext();
|
|
92
|
+
checks.push(
|
|
93
|
+
...(await attempt('accounts:verification', () =>
|
|
94
|
+
nodeAccountChecks(accounts)
|
|
95
|
+
))
|
|
96
|
+
);
|
|
77
97
|
checks.push(
|
|
78
98
|
...(await attempt('agent:verification', () =>
|
|
79
99
|
verifyNativeAgents(node, root, run, {
|
|
80
100
|
outOfQuota: quotaLimited(providers),
|
|
81
101
|
// Where T3 starts them: the environment of its user service.
|
|
82
102
|
t3Service: true,
|
|
103
|
+
...(accounts ? { accounts } : {}),
|
|
83
104
|
})
|
|
84
105
|
))
|
|
85
106
|
);
|
|
@@ -96,7 +117,8 @@ export async function convergeLinux(
|
|
|
96
117
|
*/
|
|
97
118
|
async function baselineStep(
|
|
98
119
|
root: string,
|
|
99
|
-
run: Runner
|
|
120
|
+
run: Runner,
|
|
121
|
+
node: string
|
|
100
122
|
): Promise<{ baseline: Check[]; verified: Check[] }> {
|
|
101
123
|
const measure = async () => [
|
|
102
124
|
...(await attempt('ubuntu:boot-policy', () =>
|
|
@@ -108,14 +130,14 @@ async function baselineStep(
|
|
|
108
130
|
];
|
|
109
131
|
let cached = false;
|
|
110
132
|
let baseline = await attempt('ubuntu:baseline', async () => {
|
|
111
|
-
const applied = await applyBaseline(root, run, false);
|
|
133
|
+
const applied = await applyBaseline(root, run, false, node);
|
|
112
134
|
cached = applied.cached;
|
|
113
135
|
return applied.checks;
|
|
114
136
|
});
|
|
115
137
|
let verified = await measure();
|
|
116
138
|
if (cached && verified.some((check) => check.status === 'fail')) {
|
|
117
139
|
baseline = await attempt('ubuntu:baseline', async () => {
|
|
118
|
-
return (await applyBaseline(root, run, true)).checks;
|
|
140
|
+
return (await applyBaseline(root, run, true, node)).checks;
|
|
119
141
|
});
|
|
120
142
|
verified = await measure();
|
|
121
143
|
}
|
|
@@ -125,7 +147,8 @@ async function baselineStep(
|
|
|
125
147
|
async function applyBaseline(
|
|
126
148
|
root: string,
|
|
127
149
|
run: Runner,
|
|
128
|
-
force: boolean
|
|
150
|
+
force: boolean,
|
|
151
|
+
node: string
|
|
129
152
|
): Promise<{ cached: boolean; checks: Check[] }> {
|
|
130
153
|
const user = userInfo();
|
|
131
154
|
const ubuntu = resolve(root, 'ansible/ubuntu');
|
|
@@ -140,6 +163,8 @@ async function applyBaseline(
|
|
|
140
163
|
development_user: user.username,
|
|
141
164
|
development_uid: user.uid,
|
|
142
165
|
development_home: user.homedir,
|
|
166
|
+
// The web profile brings headless Chromium (tasks/browser.yml).
|
|
167
|
+
worker_tool_profiles: workerSelection(fleet, node).profiles,
|
|
143
168
|
}),
|
|
144
169
|
},
|
|
145
170
|
{
|
|
@@ -236,13 +261,20 @@ async function convergeT3(
|
|
|
236
261
|
restarted: startedAt !== (await t3StartedAt(run)),
|
|
237
262
|
})
|
|
238
263
|
);
|
|
239
|
-
// Settings are watched by T3: providers
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
264
|
+
// Settings are watched by T3: providers, names and the profile apply
|
|
265
|
+
// without a restart.
|
|
266
|
+
checks.push(
|
|
267
|
+
...(await settingsChecks(enabled(idesOf(fleet), IDES), {
|
|
268
|
+
id: node,
|
|
269
|
+
workspace: root,
|
|
270
|
+
apply: true,
|
|
271
|
+
run,
|
|
272
|
+
}))
|
|
273
|
+
);
|
|
243
274
|
const serviceReady = checks.some(
|
|
244
275
|
(check) => check.id === 't3:service' && check.status === 'pass'
|
|
245
276
|
);
|
|
277
|
+
if (serviceReady) checks.push(await runningVersionCheck(run, userInfo().uid));
|
|
246
278
|
if (serviceReady)
|
|
247
279
|
checks.push(
|
|
248
280
|
...(await convergeT3Route(run, () => publishT3Route(root, run)))
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import type { SecretName, SecretsProvider } from './secrets';
|
|
3
|
+
import type { Check, NodeReport } from './types';
|
|
4
|
+
|
|
5
|
+
/** Where fleet-watch sends a notification. The address of each target is a
|
|
6
|
+
* secret, read by purpose: a webhook or ntfy URL often carries its own token.
|
|
7
|
+
*/
|
|
8
|
+
export const NOTIFY_TARGETS = {
|
|
9
|
+
webhook: 'notify-webhook-url',
|
|
10
|
+
ntfy: 'notify-ntfy-url',
|
|
11
|
+
} as const satisfies Record<string, SecretName>;
|
|
12
|
+
export type NotifyTarget = keyof typeof NOTIFY_TARGETS;
|
|
13
|
+
|
|
14
|
+
export interface NotifyConfig {
|
|
15
|
+
targets: NotifyTarget[];
|
|
16
|
+
/** Notify again about a machine that stays unhealthy this often; 0 never. */
|
|
17
|
+
repeatHours: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_REPEAT_HOURS = 24;
|
|
21
|
+
|
|
22
|
+
/** The `notify` block of a fleet configuration, or null when it is invalid. */
|
|
23
|
+
export function parseNotify(value: unknown): NotifyConfig | null {
|
|
24
|
+
if (!record(value)) return null;
|
|
25
|
+
if (
|
|
26
|
+
Object.keys(value).some((key) => !['targets', 'repeatHours'].includes(key))
|
|
27
|
+
)
|
|
28
|
+
return null;
|
|
29
|
+
const { targets, repeatHours = DEFAULT_REPEAT_HOURS } = value;
|
|
30
|
+
if (
|
|
31
|
+
!Array.isArray(targets) ||
|
|
32
|
+
targets.length === 0 ||
|
|
33
|
+
new Set(targets).size !== targets.length ||
|
|
34
|
+
!targets.every(
|
|
35
|
+
(target) =>
|
|
36
|
+
typeof target === 'string' && Object.hasOwn(NOTIFY_TARGETS, target)
|
|
37
|
+
) ||
|
|
38
|
+
typeof repeatHours !== 'number' ||
|
|
39
|
+
!Number.isInteger(repeatHours) ||
|
|
40
|
+
repeatHours < 0 ||
|
|
41
|
+
repeatHours > 720
|
|
42
|
+
)
|
|
43
|
+
return null;
|
|
44
|
+
return { targets: targets as NotifyTarget[], repeatHours };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the watcher remembers of each machine between two runs. */
|
|
48
|
+
export interface WatchMemory {
|
|
49
|
+
version: 1;
|
|
50
|
+
/** When the watcher last ran: the heartbeat an off-machine check reads. */
|
|
51
|
+
heartbeatAt: string;
|
|
52
|
+
nodes: Record<
|
|
53
|
+
string,
|
|
54
|
+
{ state: 'ready' | 'degraded'; since: string; notifiedAt: string | null }
|
|
55
|
+
>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One machine as fleet-status saw it: the IDs of its failing checks. */
|
|
59
|
+
export interface Observation {
|
|
60
|
+
node: string;
|
|
61
|
+
failing: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Notification {
|
|
65
|
+
event: 'degraded' | 'recovered' | 'still-degraded' | 'test';
|
|
66
|
+
node: string;
|
|
67
|
+
checks: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Only what a report concluded: the required checks that failed. */
|
|
71
|
+
export function observe(nodes: NodeReport[]): Observation[] {
|
|
72
|
+
return nodes.map((node) => ({
|
|
73
|
+
node: node.node,
|
|
74
|
+
failing: node.checks
|
|
75
|
+
.filter((check) => check.status === 'fail' && check.required !== false)
|
|
76
|
+
.map((check) => check.id),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Notifications on transitions only (ready to degraded and back), and a
|
|
81
|
+
* reminder for a machine that stays degraded longer than the repeat interval.
|
|
82
|
+
* A machine never seen before counts as ready.
|
|
83
|
+
*/
|
|
84
|
+
export function transitions(
|
|
85
|
+
previous: WatchMemory | null,
|
|
86
|
+
observed: Observation[],
|
|
87
|
+
now: Date,
|
|
88
|
+
repeatHours: number
|
|
89
|
+
): { notifications: Notification[]; memory: WatchMemory } {
|
|
90
|
+
const at = now.toISOString();
|
|
91
|
+
const notifications: Notification[] = [];
|
|
92
|
+
const nodes: WatchMemory['nodes'] = {};
|
|
93
|
+
for (const { node, failing } of observed) {
|
|
94
|
+
const before = previous?.nodes[node];
|
|
95
|
+
const state = failing.length ? 'degraded' : 'ready';
|
|
96
|
+
const checks = failing.filter(safe);
|
|
97
|
+
if (state === (before?.state ?? 'ready')) {
|
|
98
|
+
const due = state === 'degraded' && repeatDue(before, now, repeatHours);
|
|
99
|
+
if (due) notifications.push({ event: 'still-degraded', node, checks });
|
|
100
|
+
nodes[node] = before
|
|
101
|
+
? { ...before, ...(due ? { notifiedAt: at } : {}) }
|
|
102
|
+
: { state, since: at, notifiedAt: null };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
notifications.push({
|
|
106
|
+
event: state === 'degraded' ? 'degraded' : 'recovered',
|
|
107
|
+
node,
|
|
108
|
+
checks,
|
|
109
|
+
});
|
|
110
|
+
nodes[node] = { state, since: at, notifiedAt: at };
|
|
111
|
+
}
|
|
112
|
+
return { notifications, memory: { version: 1, heartbeatAt: at, nodes } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function repeatDue(
|
|
116
|
+
before: WatchMemory['nodes'][string] | undefined,
|
|
117
|
+
now: Date,
|
|
118
|
+
repeatHours: number
|
|
119
|
+
): boolean {
|
|
120
|
+
if (repeatHours <= 0 || !before?.notifiedAt) return false;
|
|
121
|
+
return (
|
|
122
|
+
now.getTime() - Date.parse(before.notifiedAt) >= repeatHours * 3_600_000
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The memory stored last time, or null when there is none to trust. */
|
|
127
|
+
export function parseMemory(text: string | null): WatchMemory | null {
|
|
128
|
+
if (!text) return null;
|
|
129
|
+
try {
|
|
130
|
+
const value: unknown = JSON.parse(text);
|
|
131
|
+
if (
|
|
132
|
+
!record(value) ||
|
|
133
|
+
value['version'] !== 1 ||
|
|
134
|
+
typeof value['heartbeatAt'] !== 'string' ||
|
|
135
|
+
!record(value['nodes'])
|
|
136
|
+
)
|
|
137
|
+
return null;
|
|
138
|
+
return value as unknown as WatchMemory;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Node and check IDs are the only words a notification carries. */
|
|
145
|
+
const safe = (id: string): boolean => /^[A-Za-z0-9:._@-]{1,128}$/.test(id);
|
|
146
|
+
|
|
147
|
+
const WORDS: Record<Notification['event'], string> = {
|
|
148
|
+
degraded: 'is degraded',
|
|
149
|
+
recovered: 'is ready again',
|
|
150
|
+
'still-degraded': 'is still degraded',
|
|
151
|
+
test: 'sent a test notification',
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function notificationText(notification: Notification): string {
|
|
155
|
+
const node = safe(notification.node) ? notification.node : 'a machine';
|
|
156
|
+
const checks = notification.checks.filter(safe);
|
|
157
|
+
return `dev-fleet: ${node} ${WORDS[notification.event]}${
|
|
158
|
+
checks.length ? `. Failing checks: ${checks.join(', ')}` : ''
|
|
159
|
+
}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sends one notification to every target. The result names the target and
|
|
163
|
+
* whether it took the notification: never its address or its answer.
|
|
164
|
+
*/
|
|
165
|
+
export async function deliver(
|
|
166
|
+
config: NotifyConfig,
|
|
167
|
+
notification: Notification,
|
|
168
|
+
secrets: SecretsProvider,
|
|
169
|
+
send: typeof fetch = fetch
|
|
170
|
+
): Promise<Check[]> {
|
|
171
|
+
const text = notificationText(notification);
|
|
172
|
+
const checks: Check[] = [];
|
|
173
|
+
for (const target of config.targets) {
|
|
174
|
+
const id = `notify:${target}`;
|
|
175
|
+
const url = await secrets(NOTIFY_TARGETS[target]);
|
|
176
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
177
|
+
checks.push({
|
|
178
|
+
id,
|
|
179
|
+
status: 'fail',
|
|
180
|
+
summary: 'NOTIFY_TARGET_UNAVAILABLE',
|
|
181
|
+
remedy: {
|
|
182
|
+
action: `Store the address of the ${target} target as the secret "${NOTIFY_TARGETS[target]}" with the secret provider fleet.json names.`,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const ok = await send(url, request(target, notification, text))
|
|
188
|
+
.then((response) => response.ok)
|
|
189
|
+
.catch(() => false);
|
|
190
|
+
checks.push(
|
|
191
|
+
ok
|
|
192
|
+
? {
|
|
193
|
+
id,
|
|
194
|
+
status: 'pass',
|
|
195
|
+
summary: `The ${target} target took the notification.`,
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
id,
|
|
199
|
+
status: 'fail',
|
|
200
|
+
summary: 'NOTIFY_DELIVERY_FAILED',
|
|
201
|
+
remedy: {
|
|
202
|
+
action: `Check that the ${target} target answers, then run devfleet fleet-watch --test.`,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return checks;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function request(
|
|
211
|
+
target: NotifyTarget,
|
|
212
|
+
notification: Notification,
|
|
213
|
+
text: string
|
|
214
|
+
): RequestInit {
|
|
215
|
+
const signal = AbortSignal.timeout(15_000);
|
|
216
|
+
if (target === 'ntfy')
|
|
217
|
+
return {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: text,
|
|
220
|
+
headers: {
|
|
221
|
+
Title: `dev-fleet: ${notification.event}`,
|
|
222
|
+
Tags:
|
|
223
|
+
notification.event === 'recovered' ? 'white_check_mark' : 'warning',
|
|
224
|
+
},
|
|
225
|
+
signal,
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({
|
|
231
|
+
source: 'dev-fleet',
|
|
232
|
+
event: notification.event,
|
|
233
|
+
node: safe(notification.node) ? notification.node : null,
|
|
234
|
+
checks: notification.checks.filter(safe),
|
|
235
|
+
text,
|
|
236
|
+
}),
|
|
237
|
+
signal,
|
|
238
|
+
};
|
|
239
|
+
}
|
package/src/platform/darwin.ts
CHANGED
|
@@ -176,19 +176,6 @@ export class DarwinPlatform implements Platform {
|
|
|
176
176
|
);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
vscodeSettingsPath(): string | null {
|
|
180
|
-
return join(
|
|
181
|
-
homedir(),
|
|
182
|
-
'Library/Application Support/Code/User/settings.json'
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
vscodeCliBinaries(): readonly string[] {
|
|
187
|
-
return [
|
|
188
|
-
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
|
|
189
|
-
].filter((p) => existsSync(p));
|
|
190
|
-
}
|
|
191
|
-
|
|
192
179
|
installScheduledJob(job: ScheduledJob): NotifierTestResult {
|
|
193
180
|
return installLaunchdJob(job);
|
|
194
181
|
}
|
package/src/platform/fallback.ts
CHANGED
|
@@ -78,14 +78,6 @@ export class FallbackPlatform implements Platform {
|
|
|
78
78
|
return [];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
vscodeSettingsPath(): string | null {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
vscodeCliBinaries(): readonly string[] {
|
|
86
|
-
return [];
|
|
87
|
-
}
|
|
88
|
-
|
|
89
81
|
installScheduledJob(job: ScheduledJob): NotifierTestResult {
|
|
90
82
|
return {
|
|
91
83
|
ok: false,
|
package/src/platform/linux.ts
CHANGED
|
@@ -115,16 +115,6 @@ export class LinuxPlatform implements Platform {
|
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
vscodeSettingsPath(): string | null {
|
|
119
|
-
const base =
|
|
120
|
-
process.env['XDG_CONFIG_HOME']?.trim() || join(homedir(), '.config');
|
|
121
|
-
return join(base, 'Code/User/settings.json');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
vscodeCliBinaries(): readonly string[] {
|
|
125
|
-
return ['/usr/share/code/bin/code'].filter((p) => existsSync(p));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
118
|
installScheduledJob(job: ScheduledJob): NotifierTestResult {
|
|
129
119
|
return installSystemdJob(job);
|
|
130
120
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { runAnsible } from './ansible';
|
|
2
2
|
import { desired } from '../t3/desired';
|
|
3
3
|
import policy from '../../ansible/ubuntu/t3/policy.json';
|
|
4
|
+
import ubuntu from '../../ansible/ubuntu/policy.json';
|
|
5
|
+
import { restartDeferral } from './t3-service';
|
|
4
6
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
7
|
import { rename, rm, symlink } from 'node:fs/promises';
|
|
6
8
|
import { userInfo } from 'node:os';
|
|
@@ -34,6 +36,7 @@ export async function stageT3Policy(
|
|
|
34
36
|
t3_policy_document: document,
|
|
35
37
|
development_user: user.username,
|
|
36
38
|
development_uid: user.uid,
|
|
39
|
+
development_home: user.homedir,
|
|
37
40
|
}),
|
|
38
41
|
timeoutMs: 120_000,
|
|
39
42
|
});
|
|
@@ -84,14 +87,21 @@ export const T3_RESTARTED_SUMMARY =
|
|
|
84
87
|
'T3 was restarted to load the new managed policy; the running server confirms it.';
|
|
85
88
|
|
|
86
89
|
/** `t3-policy.yml` restarts a running T3 that has not loaded the staged policy
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
+
* so every node offers the same providers, but only while it runs no agent
|
|
91
|
+
* turn: a busy T3 keeps its turns and the play records the restart as
|
|
92
|
+
* pending, for personal-worker-recovery to apply at the first idle check.
|
|
93
|
+
* This measures the outcome; `restarted` says whether the app restarted
|
|
94
|
+
* during this sync and so needs time to reconnect.
|
|
90
95
|
*/
|
|
91
96
|
export async function loadedT3Policy(
|
|
92
97
|
executable: string,
|
|
93
98
|
run: Runner,
|
|
94
|
-
options: {
|
|
99
|
+
options: {
|
|
100
|
+
restarted: boolean;
|
|
101
|
+
pause?: () => Promise<void>;
|
|
102
|
+
uid?: number;
|
|
103
|
+
now?: number;
|
|
104
|
+
}
|
|
95
105
|
): Promise<Check> {
|
|
96
106
|
// A restarted desktop app takes a few seconds to come back with its server.
|
|
97
107
|
const check = await verifyT3Policy(
|
|
@@ -103,8 +113,25 @@ export async function loadedT3Policy(
|
|
|
103
113
|
setTimeout(done, options.restarted ? 5000 : 1000)
|
|
104
114
|
))
|
|
105
115
|
);
|
|
106
|
-
if (check.status !== 'pass')
|
|
107
|
-
|
|
116
|
+
if (check.status !== 'pass') {
|
|
117
|
+
const deferral = await restartDeferral(
|
|
118
|
+
run,
|
|
119
|
+
options.uid ?? userInfo().uid,
|
|
120
|
+
options.now
|
|
121
|
+
);
|
|
122
|
+
if (!deferral.pending)
|
|
123
|
+
return { ...check, summary: 'T3_POLICY_RESTART_FAILED' };
|
|
124
|
+
return {
|
|
125
|
+
...check,
|
|
126
|
+
status: deferral.overdue ? 'fail' : 'warn',
|
|
127
|
+
summary: 'T3_POLICY_RESTART_PENDING',
|
|
128
|
+
remedy: {
|
|
129
|
+
action: deferral.overdue
|
|
130
|
+
? `Agent turns kept T3 from loading the staged policy for ${ubuntu.t3RestartMaxDeferralHours} hours. Finish or stop them, or restart t3code-desktop.service on the worker (this ends its running turns).`
|
|
131
|
+
: 'Agent turns are running, so T3 keeps them and loads the staged policy at the first check of personal-worker-recovery with no turn running.',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
108
135
|
return options.restarted
|
|
109
136
|
? { ...check, summary: T3_RESTARTED_SUMMARY }
|
|
110
137
|
: check;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import policy from '../../ansible/ubuntu/policy.json';
|
|
1
2
|
import type { Runner, Check } from '../types';
|
|
2
3
|
|
|
3
4
|
/** The user unit that runs the pinned desktop app (UI and embedded server). */
|
|
@@ -44,6 +45,109 @@ export async function convergeT3Service(
|
|
|
44
45
|
];
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** The recovery script, installed outside every revision (`tasks/recovery.yml`). */
|
|
49
|
+
export const T3_HEALTH_SCRIPT = '/usr/local/lib/personal-fleet/t3-health.sh';
|
|
50
|
+
|
|
51
|
+
/** The record of the package the desktop app was unpacked from
|
|
52
|
+
* (`t3-desktop.yml`): `T3-Code-<version>-<arch>.deb`. */
|
|
53
|
+
export const T3_DESKTOP_RELEASE =
|
|
54
|
+
'/opt/T3 Code (Nightly)/.personal-fleet-version';
|
|
55
|
+
|
|
56
|
+
/** Where a play records a T3 restart it deferred because agent turns were
|
|
57
|
+
* running (`tasks/t3-restart.yml`): the second it was first deferred.
|
|
58
|
+
* personal-worker-recovery clears it when it restarts the idle app. */
|
|
59
|
+
export function t3RestartMarker(uid: number): string {
|
|
60
|
+
return `/run/user/${uid}/personal-t3-restart-pending`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A restart deferred while agent turns run, and whether it has waited longer
|
|
64
|
+
* than the policy allows. */
|
|
65
|
+
export async function restartDeferral(
|
|
66
|
+
run: Runner,
|
|
67
|
+
uid: number,
|
|
68
|
+
now: number = Date.now()
|
|
69
|
+
): Promise<{ pending: boolean; overdue: boolean }> {
|
|
70
|
+
const marker = await run(['cat', t3RestartMarker(uid)]);
|
|
71
|
+
if (!marker.ok) return { pending: false, overdue: false };
|
|
72
|
+
const since = Number(marker.stdout.trim());
|
|
73
|
+
const limit = policy.t3RestartMaxDeferralHours * 3600;
|
|
74
|
+
return {
|
|
75
|
+
pending: true,
|
|
76
|
+
overdue: Number.isFinite(since) && now / 1000 - since > limit,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The release the running server reports, compared with the one installed.
|
|
81
|
+
* Asks the server itself, so it needs no `t3` on the search path. A new
|
|
82
|
+
* release is installed beside a running app, which restarts into it only
|
|
83
|
+
* while it runs no agent turn: until then this is T3_RESTART_PENDING, and a
|
|
84
|
+
* failure once the restart has waited longer than the policy allows. */
|
|
85
|
+
export async function runningVersionCheck(
|
|
86
|
+
run: Runner,
|
|
87
|
+
uid: number,
|
|
88
|
+
now: number = Date.now()
|
|
89
|
+
): Promise<Check> {
|
|
90
|
+
const server = await run(
|
|
91
|
+
[
|
|
92
|
+
'curl',
|
|
93
|
+
'--fail',
|
|
94
|
+
'--silent',
|
|
95
|
+
'--max-time',
|
|
96
|
+
'10',
|
|
97
|
+
'http://127.0.0.1:3773/.well-known/t3/environment',
|
|
98
|
+
],
|
|
99
|
+
{ timeoutMs: 15_000 }
|
|
100
|
+
);
|
|
101
|
+
const installed = await run(['cat', T3_DESKTOP_RELEASE]);
|
|
102
|
+
const running = serverVersion(server.ok ? server.stdout : '');
|
|
103
|
+
const release = /^T3-Code-(.+)-[a-z0-9]+\.deb$/.exec(
|
|
104
|
+
installed.ok ? installed.stdout.trim() : ''
|
|
105
|
+
)?.[1];
|
|
106
|
+
const id = 't3:running-version';
|
|
107
|
+
if (!running || !release)
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
status: 'fail',
|
|
111
|
+
summary: 'T3_RUNNING_VERSION_UNKNOWN',
|
|
112
|
+
remedy: {
|
|
113
|
+
action:
|
|
114
|
+
'The running server or the installed release could not be read. Check worker:t3-health, then rerun fleet-sync to reinstall the pinned desktop app.',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
if (running === release)
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
status: 'pass',
|
|
121
|
+
summary: `The running T3 server is the installed release ${release}.`,
|
|
122
|
+
};
|
|
123
|
+
const deferral = await restartDeferral(run, uid, now);
|
|
124
|
+
return {
|
|
125
|
+
id,
|
|
126
|
+
status: deferral.overdue ? 'fail' : 'warn',
|
|
127
|
+
summary: 'T3_RESTART_PENDING',
|
|
128
|
+
remedy: {
|
|
129
|
+
action: deferral.overdue
|
|
130
|
+
? `T3 still runs ${running} after ${policy.t3RestartMaxDeferralHours} hours because agent turns kept running. Finish or stop them, or restart ${T3_DESKTOP_UNIT} on the worker (this ends its running turns).`
|
|
131
|
+
: `T3 runs ${running}; ${release} is installed. personal-worker-recovery restarts it at the first check with no agent turn running.`,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function serverVersion(body: string): string | undefined {
|
|
137
|
+
try {
|
|
138
|
+
const value: unknown = JSON.parse(body);
|
|
139
|
+
const version =
|
|
140
|
+
typeof value === 'object' && value !== null
|
|
141
|
+
? (value as Record<string, unknown>)['serverVersion']
|
|
142
|
+
: undefined;
|
|
143
|
+
return typeof version === 'string' && /^[\w.+-]+$/.test(version)
|
|
144
|
+
? version
|
|
145
|
+
: undefined;
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
47
151
|
/** When the desktop app last started; changes exactly when it restarts. */
|
|
48
152
|
export async function t3StartedAt(run: Runner): Promise<string> {
|
|
49
153
|
const shown = await run([
|
package/src/platform/types.ts
CHANGED
|
@@ -110,10 +110,6 @@ export type Platform = {
|
|
|
110
110
|
|
|
111
111
|
/** App-bundled Tailscale CLI binaries that exist (used when `tailscale` is not on PATH). */
|
|
112
112
|
tailscaleAppBinaries(): readonly string[];
|
|
113
|
-
/** VS Code user `settings.json`, or `null` when unknown on this OS. */
|
|
114
|
-
vscodeSettingsPath(): string | null;
|
|
115
|
-
/** VS Code `code` CLI binaries shipped inside the installed app. */
|
|
116
|
-
vscodeCliBinaries(): readonly string[];
|
|
117
113
|
|
|
118
114
|
/** Install (or replace) a periodic job. Never throws — returns a result. */
|
|
119
115
|
installScheduledJob(job: ScheduledJob): NotifierTestResult;
|
package/src/platform/windows.ts
CHANGED
|
@@ -110,20 +110,6 @@ export class WindowsPlatform implements Platform {
|
|
|
110
110
|
);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
vscodeSettingsPath(): string | null {
|
|
114
|
-
const base =
|
|
115
|
-
process.env['APPDATA']?.trim() || join(homedir(), 'AppData/Roaming');
|
|
116
|
-
return join(base, 'Code/User/settings.json');
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
vscodeCliBinaries(): readonly string[] {
|
|
120
|
-
const base =
|
|
121
|
-
process.env['LOCALAPPDATA']?.trim() || join(homedir(), 'AppData/Local');
|
|
122
|
-
return [join(base, 'Programs/Microsoft VS Code/bin/code.cmd')].filter((p) =>
|
|
123
|
-
existsSync(p)
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
113
|
installScheduledJob(job: ScheduledJob): NotifierTestResult {
|
|
128
114
|
return {
|
|
129
115
|
ok: false,
|