@crvouga/dev-fleet 0.8.0 → 0.9.1
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 +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capability-checks.ts +55 -16
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { record } from './config';
|
|
5
|
+
import { DefinitionError, HOST_KEY } from './definition';
|
|
6
|
+
|
|
7
|
+
/** One worker as an infrastructure tool describes it, without naming the
|
|
8
|
+
* tool or the provider: schema/worker-record.schema.json. A file or command
|
|
9
|
+
* `workerRecords` in fleet.json names returns a list of them; dev-fleet only
|
|
10
|
+
* reads it and never creates a machine.
|
|
11
|
+
*/
|
|
12
|
+
export interface WorkerRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
/** The tailnet name the worker joins under; preferred to `address`. */
|
|
15
|
+
tailnetName?: string;
|
|
16
|
+
/** An address SSH reaches the worker at, when it has no tailnet name. */
|
|
17
|
+
address?: string;
|
|
18
|
+
user: string;
|
|
19
|
+
/** The port sshd listens on; 22 when absent. */
|
|
20
|
+
port?: number;
|
|
21
|
+
/** The SSH host public key, "TYPE BASE64": pinned, never learned.
|
|
22
|
+
* Without one the key is the one the fleet state pins (fleet-node-seed
|
|
23
|
+
* --apply --yes), and until then every session is refused.
|
|
24
|
+
*/
|
|
25
|
+
hostKey?: string;
|
|
26
|
+
/** The port the worker's T3 server listens on; 3773 when absent. */
|
|
27
|
+
t3Port?: number;
|
|
28
|
+
labels?: Record<string, string>;
|
|
29
|
+
billing?: { hourlyRate: number; currency?: string; note?: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where `workerRecords` reads records from. */
|
|
33
|
+
export type RecordSource = { file: string } | { command: string[] };
|
|
34
|
+
|
|
35
|
+
/** Runs a record command in the project directory; its standard output. */
|
|
36
|
+
export type RecordCommand = (
|
|
37
|
+
argv: string[],
|
|
38
|
+
directory: string
|
|
39
|
+
) => string | null;
|
|
40
|
+
|
|
41
|
+
const ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
42
|
+
const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
|
|
43
|
+
const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
44
|
+
const RECORD_FIELDS = [
|
|
45
|
+
'id',
|
|
46
|
+
'tailnetName',
|
|
47
|
+
'address',
|
|
48
|
+
'user',
|
|
49
|
+
'port',
|
|
50
|
+
'hostKey',
|
|
51
|
+
't3Port',
|
|
52
|
+
'labels',
|
|
53
|
+
'billing',
|
|
54
|
+
];
|
|
55
|
+
/** Port of the worker T3 server when a record names none. */
|
|
56
|
+
const T3_PORT = 3773;
|
|
57
|
+
|
|
58
|
+
function invalid(
|
|
59
|
+
path: string,
|
|
60
|
+
expected: string,
|
|
61
|
+
actual: unknown,
|
|
62
|
+
code = actual === undefined ? 'DEFINITION_MISSING' : 'DEFINITION_INVALID'
|
|
63
|
+
): never {
|
|
64
|
+
throw new DefinitionError({
|
|
65
|
+
code,
|
|
66
|
+
path,
|
|
67
|
+
expected,
|
|
68
|
+
actual:
|
|
69
|
+
actual === undefined
|
|
70
|
+
? 'nothing'
|
|
71
|
+
: Array.isArray(actual)
|
|
72
|
+
? `an array of ${actual.length}`
|
|
73
|
+
: record(actual)
|
|
74
|
+
? 'an object'
|
|
75
|
+
: JSON.stringify(actual).slice(0, 64),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function string(
|
|
80
|
+
value: unknown,
|
|
81
|
+
pattern: RegExp,
|
|
82
|
+
path: string,
|
|
83
|
+
expected: string
|
|
84
|
+
): string {
|
|
85
|
+
if (typeof value !== 'string' || !pattern.test(value))
|
|
86
|
+
invalid(path, expected, value);
|
|
87
|
+
return value as string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function port(value: unknown, path: string): number {
|
|
91
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
|
|
92
|
+
invalid(path, 'a port from 1 to 65535', value);
|
|
93
|
+
return value as number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One record, or a DefinitionError naming the field that is wrong. */
|
|
97
|
+
export function parseRecord(value: unknown, path: string): WorkerRecord {
|
|
98
|
+
if (!record(value)) return invalid(path, 'an object', value);
|
|
99
|
+
const unknown = Object.keys(value).find(
|
|
100
|
+
(key) => !RECORD_FIELDS.includes(key)
|
|
101
|
+
);
|
|
102
|
+
if (unknown !== undefined)
|
|
103
|
+
invalid(
|
|
104
|
+
`${path}.${unknown}`,
|
|
105
|
+
`one of ${RECORD_FIELDS.join(', ')}`,
|
|
106
|
+
value[unknown],
|
|
107
|
+
'DEFINITION_UNKNOWN'
|
|
108
|
+
);
|
|
109
|
+
const id = string(value['id'], ID, `${path}.id`, 'a lowercase id');
|
|
110
|
+
if (value['tailnetName'] === undefined && value['address'] === undefined)
|
|
111
|
+
invalid(`${path}.tailnetName`, 'a tailnet name, or an address', undefined);
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
...(value['tailnetName'] === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: {
|
|
117
|
+
tailnetName: string(
|
|
118
|
+
value['tailnetName'],
|
|
119
|
+
HOST,
|
|
120
|
+
`${path}.tailnetName`,
|
|
121
|
+
'a host name'
|
|
122
|
+
),
|
|
123
|
+
}),
|
|
124
|
+
...(value['address'] === undefined
|
|
125
|
+
? {}
|
|
126
|
+
: {
|
|
127
|
+
address: string(
|
|
128
|
+
value['address'],
|
|
129
|
+
HOST,
|
|
130
|
+
`${path}.address`,
|
|
131
|
+
'a host name or IP address'
|
|
132
|
+
),
|
|
133
|
+
}),
|
|
134
|
+
user: string(value['user'], USER, `${path}.user`, 'a login name'),
|
|
135
|
+
...(value['port'] === undefined
|
|
136
|
+
? {}
|
|
137
|
+
: { port: port(value['port'], `${path}.port`) }),
|
|
138
|
+
...(value['hostKey'] === undefined
|
|
139
|
+
? {}
|
|
140
|
+
: {
|
|
141
|
+
hostKey: string(
|
|
142
|
+
value['hostKey'],
|
|
143
|
+
HOST_KEY,
|
|
144
|
+
`${path}.hostKey`,
|
|
145
|
+
'one SSH public key, "TYPE BASE64"'
|
|
146
|
+
),
|
|
147
|
+
}),
|
|
148
|
+
...(value['t3Port'] === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { t3Port: port(value['t3Port'], `${path}.t3Port`) }),
|
|
151
|
+
...(value['labels'] === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { labels: value['labels'] as Record<string, string> }),
|
|
154
|
+
...(value['billing'] === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { billing: value['billing'] as WorkerRecord['billing'] & object }),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A record list: `{ "version": 1, "workers": [...] }`. */
|
|
161
|
+
export function parseRecords(value: unknown, path: string): WorkerRecord[] {
|
|
162
|
+
if (!record(value)) return invalid(path, 'an object', value);
|
|
163
|
+
if (value['version'] !== 1) invalid(`${path}.version`, '1', value['version']);
|
|
164
|
+
const workers = value['workers'];
|
|
165
|
+
if (!Array.isArray(workers) || workers.length > 64)
|
|
166
|
+
return invalid(
|
|
167
|
+
`${path}.workers`,
|
|
168
|
+
'an array of at most 64 records',
|
|
169
|
+
workers
|
|
170
|
+
);
|
|
171
|
+
return workers.map((entry, index) =>
|
|
172
|
+
parseRecord(entry, `${path}.workers[${index}]`)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The worker a record declares, as fleet.json would write it. Labels and
|
|
177
|
+
* billing are checked by the definition itself.
|
|
178
|
+
*/
|
|
179
|
+
export function recordWorker(entry: WorkerRecord): Record<string, unknown> {
|
|
180
|
+
return {
|
|
181
|
+
id: entry.id,
|
|
182
|
+
os: 'linux',
|
|
183
|
+
network: {
|
|
184
|
+
tailscaleName: entry.tailnetName ?? entry.address,
|
|
185
|
+
user: entry.user,
|
|
186
|
+
sshPort: entry.port ?? 22,
|
|
187
|
+
t3Port: entry.t3Port ?? T3_PORT,
|
|
188
|
+
...(entry.hostKey ? { hostKey: entry.hostKey } : {}),
|
|
189
|
+
},
|
|
190
|
+
...(entry.labels ? { labels: entry.labels } : {}),
|
|
191
|
+
...(entry.billing ? { billing: entry.billing } : {}),
|
|
192
|
+
origin: 'records',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function source(value: unknown, path: string): RecordSource {
|
|
197
|
+
if (record(value) && typeof value['file'] === 'string' && !value['command'])
|
|
198
|
+
return { file: value['file'] };
|
|
199
|
+
if (
|
|
200
|
+
record(value) &&
|
|
201
|
+
Array.isArray(value['command']) &&
|
|
202
|
+
value['command'].length > 0 &&
|
|
203
|
+
value['command'].every((entry) => typeof entry === 'string') &&
|
|
204
|
+
value['file'] === undefined
|
|
205
|
+
)
|
|
206
|
+
return { command: value['command'] as string[] };
|
|
207
|
+
return invalid(path, '{ "file": PATH } or { "command": [ARGV] }', value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Runs a record command where fleet.json is, for at most a minute. */
|
|
211
|
+
export const runRecordCommand: RecordCommand = (argv, directory) => {
|
|
212
|
+
const result = spawnSync(argv[0]!, argv.slice(1), {
|
|
213
|
+
cwd: directory,
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
timeout: 60_000,
|
|
216
|
+
maxBuffer: 1024 * 1024,
|
|
217
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
218
|
+
});
|
|
219
|
+
return result.status === 0 ? result.stdout : null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function readSource(
|
|
223
|
+
entry: RecordSource,
|
|
224
|
+
path: string,
|
|
225
|
+
directory: string,
|
|
226
|
+
command: RecordCommand
|
|
227
|
+
): unknown {
|
|
228
|
+
let text: string | null;
|
|
229
|
+
if ('file' in entry) {
|
|
230
|
+
try {
|
|
231
|
+
text = readFileSync(
|
|
232
|
+
isAbsolute(entry.file) ? entry.file : join(directory, entry.file),
|
|
233
|
+
'utf8'
|
|
234
|
+
);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// The tool that writes the file removes it with the last machine
|
|
237
|
+
// (`tofu destroy`): no file is no record.
|
|
238
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
239
|
+
return { version: 1, workers: [] };
|
|
240
|
+
text = null;
|
|
241
|
+
}
|
|
242
|
+
} else text = command(entry.command, directory);
|
|
243
|
+
if (text === null)
|
|
244
|
+
invalid(
|
|
245
|
+
`${path}.${'file' in entry ? 'file' : 'command'}`,
|
|
246
|
+
'a readable list of worker records',
|
|
247
|
+
'nothing',
|
|
248
|
+
'DEFINITION_UNREADABLE'
|
|
249
|
+
);
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(text!) as unknown;
|
|
252
|
+
} catch {
|
|
253
|
+
return invalid(
|
|
254
|
+
path,
|
|
255
|
+
'JSON',
|
|
256
|
+
'text that is not JSON',
|
|
257
|
+
'DEFINITION_UNREADABLE'
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** fleet.json with the workers its record sources return appended to the
|
|
263
|
+
* inline list, and `workerRecords` itself gone: what the rest of dev-fleet
|
|
264
|
+
* reads, installs and stores. A record whose ID is already declared is
|
|
265
|
+
* refused, naming the record.
|
|
266
|
+
*/
|
|
267
|
+
export function expandRecords(
|
|
268
|
+
value: unknown,
|
|
269
|
+
directory: string,
|
|
270
|
+
command: RecordCommand = runRecordCommand
|
|
271
|
+
): unknown {
|
|
272
|
+
if (!record(value) || value['workerRecords'] === undefined) return value;
|
|
273
|
+
const { workerRecords, ...rest } = value;
|
|
274
|
+
if (!Array.isArray(workerRecords) || workerRecords.length > 8)
|
|
275
|
+
return invalid(
|
|
276
|
+
'$.workerRecords',
|
|
277
|
+
'a list of at most 8 record sources',
|
|
278
|
+
workerRecords
|
|
279
|
+
);
|
|
280
|
+
const inline = Array.isArray(rest['workers']) ? rest['workers'] : [];
|
|
281
|
+
const seen = new Set(
|
|
282
|
+
inline.map((entry) => (record(entry) ? entry['id'] : undefined))
|
|
283
|
+
);
|
|
284
|
+
const added: Record<string, unknown>[] = [];
|
|
285
|
+
workerRecords.forEach((raw, index) => {
|
|
286
|
+
const path = `$.workerRecords[${index}]`;
|
|
287
|
+
const entry = source(raw, path);
|
|
288
|
+
const records = parseRecords(
|
|
289
|
+
readSource(entry, path, directory, command),
|
|
290
|
+
path
|
|
291
|
+
);
|
|
292
|
+
records.forEach((found, position) => {
|
|
293
|
+
if (seen.has(found.id))
|
|
294
|
+
invalid(
|
|
295
|
+
`${path}.workers[${position}].id`,
|
|
296
|
+
'an id no other worker has',
|
|
297
|
+
found.id,
|
|
298
|
+
'DEFINITION_DUPLICATE'
|
|
299
|
+
);
|
|
300
|
+
seen.add(found.id);
|
|
301
|
+
added.push(recordWorker(found));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
return { ...rest, workers: [...inline, ...added] };
|
|
305
|
+
}
|
package/src/worker-seed.ts
CHANGED
|
@@ -25,8 +25,15 @@ export interface SeedInput {
|
|
|
25
25
|
tailscale: { version: string; keySha256: string };
|
|
26
26
|
/** The codename of the fleet's Ubuntu release, from the Ubuntu policy. */
|
|
27
27
|
codename: string;
|
|
28
|
+
/** A self-hosted control server the machine joins; the hosted Tailscale's
|
|
29
|
+
* when absent.
|
|
30
|
+
*/
|
|
31
|
+
loginServer?: string;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/** The login server the Tailscale client uses when it is told none. */
|
|
35
|
+
const TAILSCALE_LOGIN_SERVER = 'https://controlplane.tailscale.com';
|
|
36
|
+
|
|
30
37
|
const PUBLIC_KEY =
|
|
31
38
|
/^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}( [^\n]*)?$/;
|
|
32
39
|
|
|
@@ -48,7 +55,14 @@ export async function seedUserData(
|
|
|
48
55
|
throw new FleetUsageError(
|
|
49
56
|
'The manager public key is not one OpenSSH public key line.'
|
|
50
57
|
);
|
|
51
|
-
|
|
58
|
+
// A Headscale key has no fixed prefix; the hosted Tailscale's does.
|
|
59
|
+
if (
|
|
60
|
+
!(
|
|
61
|
+
input.loginServer
|
|
62
|
+
? /^[A-Za-z0-9_-]{16,256}$/
|
|
63
|
+
: /^tskey-auth-[A-Za-z0-9-]{8,200}$/
|
|
64
|
+
).test(input.authKey)
|
|
65
|
+
)
|
|
52
66
|
throw new FleetUsageError(
|
|
53
67
|
'The tailnet-auth-key secret is not a Tailscale auth key (tskey-auth-…). Use a short-lived, single-use, tagged key.'
|
|
54
68
|
);
|
|
@@ -62,6 +76,7 @@ export async function seedUserData(
|
|
|
62
76
|
TAILSCALE_VERSION: input.tailscale.version,
|
|
63
77
|
TAILSCALE_KEY_SHA256: input.tailscale.keySha256,
|
|
64
78
|
CODENAME: input.codename,
|
|
79
|
+
LOGIN_SERVER: input.loginServer ?? TAILSCALE_LOGIN_SERVER,
|
|
65
80
|
};
|
|
66
81
|
const template = parse(
|
|
67
82
|
await readFile(join(root, 'bootstrap/worker-seed.yaml'), 'utf8')
|
|
@@ -102,16 +117,6 @@ export function renderSeed(
|
|
|
102
117
|
return `#cloud-config\n${stringify(document, { lineWidth: 0 })}`;
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
/** The version and key digest the worker baseline pins for Tailscale. */
|
|
106
|
-
export async function tailscalePins(
|
|
107
|
-
root = PACKAGE_ROOT
|
|
108
|
-
): Promise<SeedInput['tailscale']> {
|
|
109
|
-
const policy = JSON.parse(
|
|
110
|
-
await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
|
|
111
|
-
) as { tailscale: SeedInput['tailscale'] };
|
|
112
|
-
return policy.tailscale;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
120
|
/** A new ed25519 host key, generated in a private directory that is removed
|
|
116
121
|
* afterwards.
|
|
117
122
|
*/
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
2
|
+
|
|
3
|
+
/** How much of a machine a worker needs, and the lines its disk and memory
|
|
4
|
+
* policy draws, all declared under `sizing` in ansible/ubuntu/policy.json.
|
|
5
|
+
* Each line is a share of the disk or of memory between a lower and an upper
|
|
6
|
+
* bound, so it scales from a small rented instance to a large workstation.
|
|
7
|
+
* `tasks/sizing.yml` computes the same numbers for Ansible.
|
|
8
|
+
*/
|
|
9
|
+
export type ThresholdName = keyof typeof policy.sizing.thresholds;
|
|
10
|
+
|
|
11
|
+
export interface MachineSize {
|
|
12
|
+
memoryMiB: number;
|
|
13
|
+
/** The size of the root filesystem (or the home one, for its own lines). */
|
|
14
|
+
diskMiB: number;
|
|
15
|
+
cpus: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One line in MiB for a machine whose disk or memory (as the line says)
|
|
19
|
+
* is `totalMiB`: its share, never under its lower bound or over its upper. */
|
|
20
|
+
export function threshold(name: ThresholdName, totalMiB: number): number {
|
|
21
|
+
const line = policy.sizing.thresholds[name];
|
|
22
|
+
return Math.min(
|
|
23
|
+
line.maxMiB,
|
|
24
|
+
Math.max(line.minMiB, Math.floor(line.share * totalMiB))
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every line, for a machine of this size. */
|
|
29
|
+
export function thresholds(size: {
|
|
30
|
+
memoryMiB: number;
|
|
31
|
+
diskMiB: number;
|
|
32
|
+
}): Record<ThresholdName, number> {
|
|
33
|
+
return Object.fromEntries(
|
|
34
|
+
(Object.keys(policy.sizing.thresholds) as ThresholdName[]).map((name) => [
|
|
35
|
+
name,
|
|
36
|
+
threshold(
|
|
37
|
+
name,
|
|
38
|
+
policy.sizing.thresholds[name].of === 'memory'
|
|
39
|
+
? size.memoryMiB
|
|
40
|
+
: size.diskMiB
|
|
41
|
+
),
|
|
42
|
+
])
|
|
43
|
+
) as Record<ThresholdName, number>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const MINIMUM_WORKER: MachineSize = policy.sizing.minimumWorker;
|
|
47
|
+
|
|
48
|
+
/** What a machine lacks to be a worker, each as `memory 2048 MiB < 3584
|
|
49
|
+
* MiB`; empty when it has the stated minimum. */
|
|
50
|
+
export function belowMinimum(size: MachineSize): string[] {
|
|
51
|
+
const lacks: string[] = [];
|
|
52
|
+
if (size.memoryMiB < MINIMUM_WORKER.memoryMiB)
|
|
53
|
+
lacks.push(
|
|
54
|
+
`memory ${size.memoryMiB} MiB < ${MINIMUM_WORKER.memoryMiB} MiB`
|
|
55
|
+
);
|
|
56
|
+
if (size.diskMiB < MINIMUM_WORKER.diskMiB)
|
|
57
|
+
lacks.push(`disk ${size.diskMiB} MiB < ${MINIMUM_WORKER.diskMiB} MiB`);
|
|
58
|
+
if (size.cpus < MINIMUM_WORKER.cpus)
|
|
59
|
+
lacks.push(`CPUs ${size.cpus} < ${MINIMUM_WORKER.cpus}`);
|
|
60
|
+
return lacks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The user workloads' share of memory, from `userMemoryMax` ("90%"). */
|
|
64
|
+
function userShare(): number {
|
|
65
|
+
const percent = /^(\d{1,3})%$/.exec(policy.resourceSafety.userMemoryMax);
|
|
66
|
+
return percent ? Number(percent[1]) / 100 : 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** How many agent sessions run side by side within the memory policy:
|
|
70
|
+
* what user workloads may use, less what T3 keeps for itself, in sessions
|
|
71
|
+
* of the estimated size `sizing.agentSessionMiB`. Never below zero.
|
|
72
|
+
*/
|
|
73
|
+
export function supportedSessions(memoryMiB: number): number {
|
|
74
|
+
const usable = memoryMiB * userShare() - policy.sizing.t3ReservedMiB;
|
|
75
|
+
return Math.max(0, Math.floor(usable / policy.sizing.agentSessionMiB));
|
|
76
|
+
}
|
package/src/cli/schedule-logs.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
/** The most a scheduled job's log may hold; a longer one keeps its newest
|
|
5
|
-
* quarter.
|
|
6
|
-
*/
|
|
7
|
-
export const SCHEDULE_LOG_BYTES = 1024 * 1024;
|
|
8
|
-
|
|
9
|
-
/** Caps the schedule's own logs (`<label>.out.log`, `<label>.err.log`) at the
|
|
10
|
-
* start of every scheduled run. launchd and systemd append to them, so the
|
|
11
|
-
* newest lines are what is kept. Nothing else in the directory is touched.
|
|
12
|
-
*/
|
|
13
|
-
export function capScheduleLogs(
|
|
14
|
-
directory: string,
|
|
15
|
-
limit = SCHEDULE_LOG_BYTES
|
|
16
|
-
): void {
|
|
17
|
-
let names: string[];
|
|
18
|
-
try {
|
|
19
|
-
names = readdirSync(directory);
|
|
20
|
-
} catch {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
for (const name of names) {
|
|
24
|
-
if (!/^[a-z0-9-]+\.(out|err)\.log$/.test(name)) continue;
|
|
25
|
-
const path = join(directory, name);
|
|
26
|
-
try {
|
|
27
|
-
const stat = statSync(path);
|
|
28
|
-
if (!stat.isFile() || stat.size <= limit) continue;
|
|
29
|
-
const kept = readFileSync(path).subarray(stat.size - limit / 4);
|
|
30
|
-
const start = kept.indexOf(10) + 1;
|
|
31
|
-
writeFileSync(path, kept.subarray(start), { mode: 0o600 });
|
|
32
|
-
} catch {
|
|
33
|
-
// A log that cannot be capped is capped by the next run.
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|