@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
|
@@ -13,9 +13,11 @@ import { installConfig } from '../fleet-config';
|
|
|
13
13
|
import { IDES } from '../ide';
|
|
14
14
|
import { enabled, managerChecks } from '../ide/orchestrate';
|
|
15
15
|
import { idesOf } from '../ide/definition';
|
|
16
|
+
import { convergeGitIdentity, gitChecks } from '../git-identity';
|
|
16
17
|
import macos from '../../ansible/macos/policy.json';
|
|
17
18
|
import { ansibleCommand } from '../platform/ansible';
|
|
18
19
|
import { PACKAGE_ROOT } from '../package-info';
|
|
20
|
+
import { managerHealth } from '../manager-health';
|
|
19
21
|
import { readFileSync } from 'node:fs';
|
|
20
22
|
import { join } from 'node:path';
|
|
21
23
|
|
|
@@ -50,6 +52,7 @@ export async function convergeManager(
|
|
|
50
52
|
run,
|
|
51
53
|
}))
|
|
52
54
|
);
|
|
55
|
+
checks.push(...(await managerHealth(run)));
|
|
53
56
|
if (input.apply) {
|
|
54
57
|
// Same provider profiles and the same Vault gateway credential as every
|
|
55
58
|
// worker: the instances above launch from these profiles, and the check
|
|
@@ -59,6 +62,14 @@ export async function convergeManager(
|
|
|
59
62
|
managerAgentProfiles(id, input.workspace, run)
|
|
60
63
|
))
|
|
61
64
|
);
|
|
65
|
+
// The same Git identity and GitHub credentials as every worker.
|
|
66
|
+
checks.push(
|
|
67
|
+
...(await attempt('git:configuration', () =>
|
|
68
|
+
convergeGitIdentity(input.workspace, definition.git, run)
|
|
69
|
+
))
|
|
70
|
+
);
|
|
71
|
+
if (definition.git)
|
|
72
|
+
checks.push(...(await gitChecks(definition.git, run)));
|
|
62
73
|
const providers = await attempt('provider:verification', () =>
|
|
63
74
|
verifyFleetProviders(id)
|
|
64
75
|
);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import type { Standing } from '../state/sync';
|
|
3
|
+
import type { Runner } from '../types';
|
|
4
|
+
|
|
5
|
+
/** A tool whose pin changes between the applied and the selected revision. */
|
|
6
|
+
export interface ToolChange {
|
|
7
|
+
tool: string;
|
|
8
|
+
from: string | null;
|
|
9
|
+
to: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Something a sync restarts, and whether that ends work in progress. */
|
|
13
|
+
export interface Restart {
|
|
14
|
+
service: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
endsRunningTurns: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What `fleet-sync --dry-run` says a sync would change. It is read from
|
|
20
|
+
* Git (the two revisions' tool pins and the files between them) and the
|
|
21
|
+
* fleet state; nothing on a machine is changed to find it out.
|
|
22
|
+
*/
|
|
23
|
+
export interface SyncPlan {
|
|
24
|
+
/** How the fleet state stood with the bucket when the plan was made. */
|
|
25
|
+
state: Standing;
|
|
26
|
+
revision: { from: string | null; to: string };
|
|
27
|
+
/** False when the applied revision could not be read: the changes are
|
|
28
|
+
* then unknown, not absent.
|
|
29
|
+
*/
|
|
30
|
+
known: boolean;
|
|
31
|
+
tools: ToolChange[];
|
|
32
|
+
restarts: Restart[];
|
|
33
|
+
nodes: { node: string; from: string | null; to: string; changes: boolean }[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The coding agents' CLIs: a new version is used by the next session, and a
|
|
37
|
+
* running one keeps the binary it started with.
|
|
38
|
+
*/
|
|
39
|
+
const AGENT_TOOLS = [
|
|
40
|
+
'claude',
|
|
41
|
+
'codex',
|
|
42
|
+
'gemini',
|
|
43
|
+
'copilot',
|
|
44
|
+
'aqua:anomalyco/opencode',
|
|
45
|
+
'http:grok',
|
|
46
|
+
'http:antigravity',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function section(text: string, name: string): string {
|
|
50
|
+
const start = text.search(new RegExp(`^\\[${name}\\]\\s*$`, 'm'));
|
|
51
|
+
if (start < 0) return '';
|
|
52
|
+
const rest = text.slice(start).split('\n').slice(1);
|
|
53
|
+
const end = rest.findIndex((line) => /^\[/.test(line));
|
|
54
|
+
return (end < 0 ? rest : rest.slice(0, end)).join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The exact pins under [tools], and T3's under [vars]. */
|
|
58
|
+
export function pins(miseToml: string): Map<string, string> {
|
|
59
|
+
const found = new Map<string, string>();
|
|
60
|
+
for (const line of section(miseToml, 'tools').split('\n')) {
|
|
61
|
+
const match =
|
|
62
|
+
/^\s*"?([A-Za-z0-9@:/._-]+)"?\s*=\s*(?:"([^"]+)"|\{.*?\bversion\s*=\s*"([^"]+)")/.exec(
|
|
63
|
+
line
|
|
64
|
+
);
|
|
65
|
+
if (match) found.set(match[1]!, (match[2] ?? match[3])!);
|
|
66
|
+
}
|
|
67
|
+
const t3 = /^\s*t3_version\s*=\s*"([^"]+)"/m.exec(section(miseToml, 'vars'));
|
|
68
|
+
if (t3) found.set('t3', t3[1]!);
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function toolChanges(
|
|
73
|
+
before: Map<string, string>,
|
|
74
|
+
after: Map<string, string>
|
|
75
|
+
): ToolChange[] {
|
|
76
|
+
const names = [...new Set([...before.keys(), ...after.keys()])].sort();
|
|
77
|
+
return names
|
|
78
|
+
.filter((tool) => before.get(tool) !== after.get(tool))
|
|
79
|
+
.map((tool) => ({
|
|
80
|
+
tool,
|
|
81
|
+
from: before.get(tool) ?? null,
|
|
82
|
+
to: after.get(tool) ?? null,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What restarts follow from the changes, as the playbooks and the toolchain
|
|
87
|
+
* setup act on them.
|
|
88
|
+
*/
|
|
89
|
+
export function restarts(tools: ToolChange[], files: string[]): Restart[] {
|
|
90
|
+
const found: Restart[] = [];
|
|
91
|
+
if (tools.some((change) => change.tool === 't3'))
|
|
92
|
+
found.push({
|
|
93
|
+
service: 't3',
|
|
94
|
+
reason:
|
|
95
|
+
'T3 is upgraded and restarted on every machine; its running agent turns end.',
|
|
96
|
+
endsRunningTurns: true,
|
|
97
|
+
});
|
|
98
|
+
else if (
|
|
99
|
+
files.some((file) =>
|
|
100
|
+
/^(agents\/|ansible\/ubuntu\/(t3-policy\.yml|t3\/))/.test(file)
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
found.push({
|
|
104
|
+
service: 't3',
|
|
105
|
+
reason:
|
|
106
|
+
'The managed T3 policy may change: a T3 that has not loaded it is restarted, and its running agent turns end.',
|
|
107
|
+
endsRunningTurns: true,
|
|
108
|
+
});
|
|
109
|
+
const agents = tools.filter((change) => AGENT_TOOLS.includes(change.tool));
|
|
110
|
+
if (agents.length)
|
|
111
|
+
found.push({
|
|
112
|
+
service: 'agent-clis',
|
|
113
|
+
reason: `New sessions use the new ${agents.map((change) => change.tool).join(', ')}; running turns keep the version they started with.`,
|
|
114
|
+
endsRunningTurns: false,
|
|
115
|
+
});
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function read(
|
|
120
|
+
run: Runner,
|
|
121
|
+
cwd: string,
|
|
122
|
+
args: string[]
|
|
123
|
+
): Promise<string | null> {
|
|
124
|
+
const result = await run(['git', ...args], { cwd, timeoutMs: 20_000 });
|
|
125
|
+
return result.ok ? result.stdout : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read-only: the plan of a sync from `from` (the revision the fleet last
|
|
129
|
+
* applied) to `to`. Git objects come from the checkout this runs in or the
|
|
130
|
+
* manager's cache of the source repository; nothing is fetched.
|
|
131
|
+
*/
|
|
132
|
+
export async function syncPlan(
|
|
133
|
+
input: {
|
|
134
|
+
from: string | null;
|
|
135
|
+
to: string;
|
|
136
|
+
state: Standing;
|
|
137
|
+
nodes: string[];
|
|
138
|
+
sources: string[];
|
|
139
|
+
},
|
|
140
|
+
run: Runner
|
|
141
|
+
): Promise<SyncPlan> {
|
|
142
|
+
let changes: { tools: ToolChange[]; files: string[] } | null =
|
|
143
|
+
input.from === input.to ? { tools: [], files: [] } : null;
|
|
144
|
+
for (const cwd of input.from && !changes ? input.sources : []) {
|
|
145
|
+
const before = await read(run, cwd, ['show', `${input.from}:mise.toml`]);
|
|
146
|
+
const after = await read(run, cwd, ['show', `${input.to}:mise.toml`]);
|
|
147
|
+
const files = await read(run, cwd, [
|
|
148
|
+
'diff',
|
|
149
|
+
'--name-only',
|
|
150
|
+
input.from!,
|
|
151
|
+
input.to,
|
|
152
|
+
]);
|
|
153
|
+
if (before === null || after === null || files === null) continue;
|
|
154
|
+
changes = {
|
|
155
|
+
tools: toolChanges(pins(before), pins(after)),
|
|
156
|
+
files: files.split('\n').filter(Boolean),
|
|
157
|
+
};
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const differs = input.from !== input.to;
|
|
161
|
+
return {
|
|
162
|
+
state: input.state,
|
|
163
|
+
revision: { from: input.from, to: input.to },
|
|
164
|
+
known: changes !== null,
|
|
165
|
+
tools: changes?.tools ?? [],
|
|
166
|
+
restarts: changes ? restarts(changes.tools, changes.files) : [],
|
|
167
|
+
nodes: input.nodes.map((node) => ({
|
|
168
|
+
node,
|
|
169
|
+
from: input.from,
|
|
170
|
+
to: input.to,
|
|
171
|
+
changes: differs,
|
|
172
|
+
})),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const planSources = (workspace: string, root: string): string[] => [
|
|
177
|
+
workspace,
|
|
178
|
+
join(root, 'source'),
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/** The plan, for people: one line per change. */
|
|
182
|
+
export function planText(plan: SyncPlan): string {
|
|
183
|
+
const short = (revision: string | null) => revision?.slice(0, 12) ?? 'none';
|
|
184
|
+
const lines = [
|
|
185
|
+
plan.revision.from === plan.revision.to
|
|
186
|
+
? `Plan: the fleet stays at ${short(plan.revision.to)}.`
|
|
187
|
+
: `Plan: ${short(plan.revision.from)} → ${short(plan.revision.to)} on ${plan.nodes.map((node) => node.node).join(', ') || 'no machine'}.`,
|
|
188
|
+
];
|
|
189
|
+
if (!plan.known)
|
|
190
|
+
lines.push(
|
|
191
|
+
' The applied revision is not in a local repository: tool and restart changes are unknown.'
|
|
192
|
+
);
|
|
193
|
+
for (const change of plan.tools)
|
|
194
|
+
lines.push(
|
|
195
|
+
` ${change.tool} ${change.from ?? 'not installed'} → ${change.to ?? 'removed'}`
|
|
196
|
+
);
|
|
197
|
+
for (const restart of plan.restarts)
|
|
198
|
+
lines.push(` Restarts ${restart.service}: ${restart.reason}`);
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
@@ -193,7 +193,9 @@ function deployWithMonitoring(
|
|
|
193
193
|
if (ui.interactiveAuthorization) ui.progress.stop();
|
|
194
194
|
const before = await authorizeVault(node, vault, run);
|
|
195
195
|
ui.progress.step(node.id, 'Converging (remote lock, install, verify)');
|
|
196
|
-
const result = await bootstrapRemote(node, payload, run
|
|
196
|
+
const result = await bootstrapRemote(node, payload, run, {
|
|
197
|
+
progress: (line) => ui.progress.step(node.id, line),
|
|
198
|
+
});
|
|
197
199
|
// A fresh node gets vault from the bootstrap; log it in now. Its provider
|
|
198
200
|
// checks in this run predate the login, so the next sync verifies them.
|
|
199
201
|
result.checks.push(
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
1
3
|
import { join } from 'node:path';
|
|
2
4
|
import { currentPlatform } from '../platform';
|
|
3
5
|
import type { Platform } from '../platform/types';
|
|
6
|
+
import { PACKAGE_ROOT, packageInfo, type PackageInfo } from '../package-info';
|
|
7
|
+
import {
|
|
8
|
+
readHistory,
|
|
9
|
+
recordSchedule,
|
|
10
|
+
type ScheduleRecord,
|
|
11
|
+
} from '../state/history';
|
|
12
|
+
import { scheduleCheck, SCHEDULE_INTERVAL_SECONDS } from '../schedule-check';
|
|
4
13
|
import type { Runner } from '../types';
|
|
5
14
|
import type { CommandOutcome } from './contracts';
|
|
6
15
|
import { machineRoot } from './fleet-sync';
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+
/** The launchd label and systemd unit already on running managers. */
|
|
18
|
+
export const SCHEDULE_LABEL = 'personal-fleet-sync';
|
|
19
|
+
const LABEL = SCHEDULE_LABEL;
|
|
11
20
|
export type ScheduleAction =
|
|
12
21
|
'enable' | 'pause' | 'resume' | 'disable' | 'status';
|
|
13
22
|
interface ScheduleInput {
|
|
@@ -15,33 +24,145 @@ interface ScheduleInput {
|
|
|
15
24
|
revision: string;
|
|
16
25
|
config: string;
|
|
17
26
|
authorized: boolean;
|
|
27
|
+
/** The package this CLI runs from; the schedule installs the same one. */
|
|
28
|
+
release?: Release;
|
|
29
|
+
/** Where the schedule is recorded in the fleet state; tests replace it. */
|
|
30
|
+
record?: (schedule: ScheduleRecord) => void;
|
|
31
|
+
/** The manager's caches; tests name their own. */
|
|
32
|
+
root?: string;
|
|
33
|
+
now?: Date;
|
|
18
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The published package a schedule installs and runs: its name, exact
|
|
37
|
+
* version and the entry point its manifest declares.
|
|
38
|
+
*/
|
|
39
|
+
export interface Release extends PackageInfo {
|
|
40
|
+
bin: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function release(root = PACKAGE_ROOT): Release {
|
|
44
|
+
let bin: unknown = null;
|
|
45
|
+
try {
|
|
46
|
+
const manifest = JSON.parse(
|
|
47
|
+
readFileSync(join(root, 'package.json'), 'utf8')
|
|
48
|
+
) as { bin?: unknown };
|
|
49
|
+
bin = typeof manifest.bin === 'string' ? manifest.bin : null;
|
|
50
|
+
if (typeof manifest.bin === 'object' && manifest.bin !== null)
|
|
51
|
+
bin = (manifest.bin as Record<string, unknown>)['devfleet'] ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
bin = null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...packageInfo(root),
|
|
57
|
+
bin: typeof bin === 'string' ? bin.replace(/^\.\//, '') : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Only a published release can be scheduled: it names an exact version and
|
|
62
|
+
* the commit it was built from, so every unattended run is the same code.
|
|
63
|
+
*/
|
|
64
|
+
export function publishedRelease(release: Release): boolean {
|
|
65
|
+
return (
|
|
66
|
+
/^\d+\.\d+\.\d+$/.test(release.version) &&
|
|
67
|
+
release.revision !== null &&
|
|
68
|
+
release.bin !== null &&
|
|
69
|
+
!release.bin.split('/').includes('..')
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What launchd (or the systemd timer) runs: the installed package's own
|
|
74
|
+
* entry point under Node, with the tools fleet-sync installs on PATH. No path
|
|
75
|
+
* into the project or the package the operator ran is written into it.
|
|
76
|
+
*/
|
|
77
|
+
export function scheduledCommand(
|
|
78
|
+
release: Release,
|
|
79
|
+
revision: string,
|
|
80
|
+
prefix: string
|
|
81
|
+
): string[] {
|
|
82
|
+
return [
|
|
83
|
+
'/usr/bin/env',
|
|
84
|
+
'node',
|
|
85
|
+
join(prefix, 'node_modules', release.name, release.bin ?? ''),
|
|
86
|
+
'fleet-sync',
|
|
87
|
+
'--revision',
|
|
88
|
+
revision,
|
|
89
|
+
'--apply',
|
|
90
|
+
'--yes',
|
|
91
|
+
'--non-interactive',
|
|
92
|
+
'--json',
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function scheduledEnvironment(home = homedir()): Record<string, string> {
|
|
97
|
+
return {
|
|
98
|
+
PATH: [
|
|
99
|
+
join(home, '.local/share/mise/shims'),
|
|
100
|
+
join(home, '.local/bin'),
|
|
101
|
+
'/opt/homebrew/bin',
|
|
102
|
+
'/usr/local/bin',
|
|
103
|
+
'/usr/bin',
|
|
104
|
+
'/bin',
|
|
105
|
+
].join(':'),
|
|
106
|
+
DEVFLEET_TRIGGER: 'schedule',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
19
110
|
export async function managerSchedule(
|
|
20
111
|
input: ScheduleInput,
|
|
21
112
|
run: Runner,
|
|
22
113
|
platform: Platform = currentPlatform()
|
|
23
114
|
): Promise<CommandOutcome> {
|
|
24
|
-
if (input.action === 'status')
|
|
25
|
-
return {
|
|
26
|
-
exitCode: 0,
|
|
27
|
-
data: {
|
|
28
|
-
version: 2,
|
|
29
|
-
command: 'fleet-sync',
|
|
30
|
-
execution: 'manager-push',
|
|
31
|
-
scheduling: scheduleStatus(platform),
|
|
32
|
-
},
|
|
33
|
-
};
|
|
115
|
+
if (input.action === 'status') return statusReport(platform, input.now);
|
|
34
116
|
if (!input.authorized)
|
|
35
117
|
return required('Manager scheduling changes require --apply --yes.');
|
|
36
118
|
if (input.action === 'pause' || input.action === 'disable')
|
|
37
119
|
return removeSchedule(input, platform);
|
|
38
120
|
return enableSchedule(input, run, platform);
|
|
39
121
|
}
|
|
122
|
+
|
|
123
|
+
function statusReport(platform: Platform, now = new Date()): CommandOutcome {
|
|
124
|
+
const history = readHistory();
|
|
125
|
+
const check = scheduleCheck(history, now);
|
|
126
|
+
return {
|
|
127
|
+
exitCode: 0,
|
|
128
|
+
data: {
|
|
129
|
+
version: 2,
|
|
130
|
+
command: 'fleet-sync',
|
|
131
|
+
execution: 'manager-push',
|
|
132
|
+
scheduling: scheduleStatus(platform),
|
|
133
|
+
intervalSeconds:
|
|
134
|
+
history.schedule?.intervalSeconds ?? SCHEDULE_INTERVAL_SECONDS,
|
|
135
|
+
runs: history.runs
|
|
136
|
+
.filter((entry) => entry.trigger === 'schedule')
|
|
137
|
+
.slice(-3)
|
|
138
|
+
.reverse(),
|
|
139
|
+
...(check ? { checks: [check] } : {}),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function remember(
|
|
145
|
+
input: ScheduleInput,
|
|
146
|
+
enabled: boolean,
|
|
147
|
+
intervalSeconds = SCHEDULE_INTERVAL_SECONDS
|
|
148
|
+
): void {
|
|
149
|
+
try {
|
|
150
|
+
(input.record ?? recordSchedule)({
|
|
151
|
+
enabled,
|
|
152
|
+
intervalSeconds,
|
|
153
|
+
changedAt: (input.now ?? new Date()).toISOString(),
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// The schedule changed either way; the next run records it again.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
40
160
|
function removeSchedule(
|
|
41
161
|
input: ScheduleInput,
|
|
42
162
|
platform: Platform
|
|
43
163
|
): CommandOutcome {
|
|
44
164
|
const result = platform.removeScheduledJob(LABEL);
|
|
165
|
+
if (result.ok) remember(input, false);
|
|
45
166
|
return {
|
|
46
167
|
exitCode: result.ok ? 0 : 1,
|
|
47
168
|
data: {
|
|
@@ -60,15 +181,12 @@ async function enableSchedule(
|
|
|
60
181
|
run: Runner,
|
|
61
182
|
platform: Platform
|
|
62
183
|
): Promise<CommandOutcome> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
!/^\d+\.\d+\.\d+$/.test(PERSONAL_VERSION) ||
|
|
66
|
-
typeof PERSONAL_SOURCE_REVISION !== 'string'
|
|
67
|
-
)
|
|
184
|
+
const published = input.release ?? release();
|
|
185
|
+
if (!publishedRelease(published))
|
|
68
186
|
return required(
|
|
69
187
|
'Scheduling requires a published CLI with clean source provenance; dirty review artifacts cannot enable unattended deployment.'
|
|
70
188
|
);
|
|
71
|
-
const root = machineRoot();
|
|
189
|
+
const root = input.root ?? machineRoot();
|
|
72
190
|
const prefix = join(root, 'runtime');
|
|
73
191
|
const installed = await run(
|
|
74
192
|
[
|
|
@@ -79,7 +197,7 @@ async function enableSchedule(
|
|
|
79
197
|
'--ignore-scripts',
|
|
80
198
|
'--no-audit',
|
|
81
199
|
'--no-fund',
|
|
82
|
-
|
|
200
|
+
`${published.name}@${published.version}`,
|
|
83
201
|
],
|
|
84
202
|
{ timeoutMs: 120_000 }
|
|
85
203
|
);
|
|
@@ -89,23 +207,12 @@ async function enableSchedule(
|
|
|
89
207
|
);
|
|
90
208
|
const result = platform.installScheduledJob({
|
|
91
209
|
label: LABEL,
|
|
92
|
-
intervalSeconds:
|
|
93
|
-
command:
|
|
94
|
-
process.execPath,
|
|
95
|
-
join(prefix, 'node_modules/dev-fleet/cli.mjs'),
|
|
96
|
-
'fleet-sync',
|
|
97
|
-
'--config',
|
|
98
|
-
input.config,
|
|
99
|
-
'--revision',
|
|
100
|
-
input.revision,
|
|
101
|
-
'--apply',
|
|
102
|
-
'--yes',
|
|
103
|
-
'--non-interactive',
|
|
104
|
-
'--json',
|
|
105
|
-
],
|
|
210
|
+
intervalSeconds: SCHEDULE_INTERVAL_SECONDS,
|
|
211
|
+
command: scheduledCommand(published, input.revision, prefix),
|
|
106
212
|
logDir: join(root, 'logs'),
|
|
107
|
-
env:
|
|
213
|
+
env: scheduledEnvironment(),
|
|
108
214
|
});
|
|
215
|
+
if (result.ok) remember(input, true);
|
|
109
216
|
return {
|
|
110
217
|
exitCode: result.ok ? 0 : 1,
|
|
111
218
|
data: {
|
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -20,13 +20,22 @@ import {
|
|
|
20
20
|
} from '../machine/state';
|
|
21
21
|
import { loadEnrollment } from '../state/enrollment';
|
|
22
22
|
import { legacyHome } from '../state/paths';
|
|
23
|
+
import {
|
|
24
|
+
appendRun,
|
|
25
|
+
summarizeRun,
|
|
26
|
+
type SyncRun,
|
|
27
|
+
type Trigger,
|
|
28
|
+
} from '../state/history';
|
|
29
|
+
import { capScheduleLogs } from './schedule-logs';
|
|
23
30
|
import {
|
|
24
31
|
DIVERGED_REMEDY,
|
|
32
|
+
mutateState,
|
|
25
33
|
refresh,
|
|
26
34
|
stateContext,
|
|
27
35
|
type Standing,
|
|
28
36
|
} from '../state/sync';
|
|
29
37
|
import { runRecord } from './fleet-sync-state';
|
|
38
|
+
import { planSources, planText, syncPlan } from './fleet-sync-plan';
|
|
30
39
|
import { runCommand } from '../runner';
|
|
31
40
|
import type { Runner } from '../types';
|
|
32
41
|
import type { Settings } from './settings';
|
|
@@ -84,11 +93,43 @@ export async function fleetSync(
|
|
|
84
93
|
return offManager(options, settings, run, forward);
|
|
85
94
|
if (options.schedule === 'status') return scheduleStatus(settings, run);
|
|
86
95
|
if (options.only === 'accounts') return onlyAccounts(options, settings, run);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
const sync = () =>
|
|
97
|
+
managerSync(options, settings, {
|
|
98
|
+
run,
|
|
99
|
+
root: machineRoot(),
|
|
100
|
+
restart: sync,
|
|
101
|
+
});
|
|
102
|
+
if (options.dryRun || options.schedule) return sync();
|
|
103
|
+
const trigger = runTrigger();
|
|
104
|
+
if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
|
|
105
|
+
const startedAt = new Date();
|
|
106
|
+
const outcome = await sync();
|
|
107
|
+
await recordRun(
|
|
108
|
+
summarizeRun(
|
|
109
|
+
outcome.data,
|
|
110
|
+
{ startedAt, finishedAt: new Date() },
|
|
111
|
+
trigger,
|
|
112
|
+
outcome.exitCode
|
|
113
|
+
),
|
|
114
|
+
run
|
|
115
|
+
);
|
|
116
|
+
return outcome;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Runs the manager's schedule starts say so in their environment. */
|
|
120
|
+
function runTrigger(env = process.env): Trigger {
|
|
121
|
+
return env['DEVFLEET_TRIGGER'] === 'schedule' ? 'schedule' : 'operator';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Appends the run to the history in the fleet state and uploads it. A
|
|
125
|
+
* history that cannot be written never changes the run's result.
|
|
126
|
+
*/
|
|
127
|
+
async function recordRun(entry: SyncRun, run: Runner): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await mutateState(stateContext(run), () => appendRun(entry));
|
|
130
|
+
} catch {
|
|
131
|
+
// Reported by the next fleet-state or fleet-sync run.
|
|
132
|
+
}
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
export interface ManagerSync {
|
|
@@ -132,9 +173,9 @@ export async function managerSync(
|
|
|
132
173
|
revision
|
|
133
174
|
);
|
|
134
175
|
if (rehomed !== 'here') return rehomed;
|
|
135
|
-
|
|
136
|
-
//
|
|
137
|
-
//
|
|
176
|
+
// The bucket is the source of truth: a run, and a preview, starts from its
|
|
177
|
+
// version of the configuration, the roster and the approval, not from a
|
|
178
|
+
// stale local copy. Refreshing never uploads.
|
|
138
179
|
const fresh = await refresh(stateContext(run));
|
|
139
180
|
if (fresh.standing === 'diverged')
|
|
140
181
|
return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
|
|
@@ -144,6 +185,13 @@ export async function managerSync(
|
|
|
144
185
|
reloadFleet();
|
|
145
186
|
return restart();
|
|
146
187
|
}
|
|
188
|
+
if (options.dryRun)
|
|
189
|
+
return preview({ ...options, revision }, settings, {
|
|
190
|
+
run,
|
|
191
|
+
root,
|
|
192
|
+
applied: state?.appliedRevision ?? null,
|
|
193
|
+
standing: fresh.standing,
|
|
194
|
+
});
|
|
147
195
|
return authorizeAndApply(root, state, options, {
|
|
148
196
|
settings,
|
|
149
197
|
run,
|
|
@@ -152,6 +200,40 @@ export async function managerSync(
|
|
|
152
200
|
});
|
|
153
201
|
}
|
|
154
202
|
|
|
203
|
+
/** The preview: the read-only report of every machine, and the plan of what
|
|
204
|
+
* a sync would change on them.
|
|
205
|
+
*/
|
|
206
|
+
async function preview(
|
|
207
|
+
options: FleetSyncOptions & { revision: string },
|
|
208
|
+
settings: Settings,
|
|
209
|
+
context: {
|
|
210
|
+
run: Runner;
|
|
211
|
+
root: string;
|
|
212
|
+
applied: string | null;
|
|
213
|
+
standing: Standing;
|
|
214
|
+
}
|
|
215
|
+
): Promise<CommandOutcome> {
|
|
216
|
+
const outcome = await selected(options, settings, context.run);
|
|
217
|
+
const report = outcome.data as { nodes?: { node?: unknown }[] };
|
|
218
|
+
const plan = await syncPlan(
|
|
219
|
+
{
|
|
220
|
+
from: context.applied,
|
|
221
|
+
to: options.revision,
|
|
222
|
+
state: context.standing,
|
|
223
|
+
nodes: (report.nodes ?? [])
|
|
224
|
+
.map((node) => node.node)
|
|
225
|
+
.filter((node): node is string => typeof node === 'string'),
|
|
226
|
+
sources: planSources(settings.workspace, context.root),
|
|
227
|
+
},
|
|
228
|
+
context.run
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
...outcome,
|
|
232
|
+
data: { ...(outcome.data as object), plan },
|
|
233
|
+
text: [outcome.text, planText(plan)].filter(Boolean).join('\n'),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
155
237
|
interface ApplyContext {
|
|
156
238
|
settings: Settings;
|
|
157
239
|
run: Runner;
|