@crvouga/dev-fleet 0.4.0 → 0.6.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 +3 -1
- package/ansible/macos/policy.json +1 -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 +186 -4
- package/ansible/ubuntu/policy.json +18 -3
- package/ansible/ubuntu/t3-desktop.yml +2 -2
- package/ansible/ubuntu/tailscale.yml +20 -9
- package/ansible/ubuntu/tasks/dpkg.yml +46 -0
- package/ansible/ubuntu/tasks/github.yml +50 -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 +6 -5
- package/ansible/ubuntu/tasks/snapshot.yml +50 -0
- package/ansible/ubuntu/tasks/unattended-upgrades.yml +24 -0
- package/bootstrap/fleet-remote-bootstrap.sh +18 -41
- package/bootstrap/worker-auth.ts +6 -7
- package/bootstrap/worker-github.ts +55 -44
- package/docs/authentication.md +4 -1
- package/docs/consumer-setup.md +37 -30
- package/docs/declared.md +4 -5
- package/docs/github.md +107 -0
- package/docs/operations.md +1 -1
- package/docs/runbooks.md +17 -6
- package/docs/support-matrix.md +36 -0
- package/docs/ubuntu-sync.md +14 -12
- package/docs/worker-contract.md +28 -1
- package/package.json +2 -2
- package/schema/check-ids.json +55 -7
- package/schema/fleet.schema.json +86 -4
- package/src/auth-checks.ts +14 -6
- package/src/cli/fleet-sync-manager.ts +9 -0
- package/src/cli/fleet-sync-remote.ts +1 -1
- package/src/cli/github-verify.ts +57 -0
- package/src/cli/register-fleet.ts +2 -0
- package/src/cli/registry.ts +7 -0
- package/src/definition.ts +29 -8
- package/src/github/checks.ts +412 -0
- package/src/github/converge.ts +62 -0
- package/src/github/copilot.ts +54 -0
- package/src/github/credential.ts +21 -0
- package/src/github/policy.ts +141 -0
- package/src/github/sync.ts +123 -0
- package/src/github/workflow.ts +223 -0
- package/src/machine/platform.ts +48 -28
- package/src/machine/support-matrix.ts +76 -0
- package/src/manager-sync.ts +15 -7
- package/src/network-definition.ts +1 -1
- package/src/node-converge-linux.ts +3 -2
- package/src/node-converge-shared.ts +6 -2
- package/src/node-converge.ts +1 -1
- 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 +2 -0
- package/src/repair-catalog.ts +2 -2
- package/src/secrets.ts +58 -13
- package/src/service-auth.ts +2 -2
- package/src/worker-access-checks.ts +66 -16
- package/src/worker-host-checks.ts +36 -10
- package/src/worker-readiness.ts +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import macos from '../../ansible/macos/policy.json';
|
|
2
|
+
import platforms from '../../ansible/platforms.json';
|
|
3
|
+
import { UBUNTU_RELEASES } from '../platform/ubuntu-release';
|
|
4
|
+
|
|
5
|
+
export type SupportStatus = 'supported' | 'best effort' | 'refused';
|
|
6
|
+
export interface SupportRow {
|
|
7
|
+
role: string;
|
|
8
|
+
platform: string;
|
|
9
|
+
versions: string;
|
|
10
|
+
architectures: string;
|
|
11
|
+
status: SupportStatus;
|
|
12
|
+
note: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every platform and what dev-fleet does with it, from the policies the code
|
|
16
|
+
* reads: the supported rows from the macOS and Ubuntu policies, the rest from
|
|
17
|
+
* ansible/platforms.json. docs/support-matrix.md is this table. */
|
|
18
|
+
export function supportMatrix(): SupportRow[] {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
role: 'manager',
|
|
22
|
+
platform: 'macOS',
|
|
23
|
+
versions: `${macos.minimumMajorVersion} or newer`,
|
|
24
|
+
architectures: macos.architectures.join(', '),
|
|
25
|
+
status: 'supported',
|
|
26
|
+
note: 'ansible/macos/policy.json',
|
|
27
|
+
},
|
|
28
|
+
...UBUNTU_RELEASES.map((entry): SupportRow => ({
|
|
29
|
+
role: 'worker',
|
|
30
|
+
platform: 'Ubuntu',
|
|
31
|
+
versions: `${entry.release} (${entry.codename})`,
|
|
32
|
+
architectures: entry.architectures.join(', '),
|
|
33
|
+
status: 'supported',
|
|
34
|
+
note: 'ansible/ubuntu/policy.json; a machine of its own, physical or virtual',
|
|
35
|
+
})),
|
|
36
|
+
...platforms.rows.map((row): SupportRow => ({
|
|
37
|
+
role: row.role,
|
|
38
|
+
platform: row.platform,
|
|
39
|
+
versions: row.versions,
|
|
40
|
+
architectures: row.architectures,
|
|
41
|
+
status: row.status as SupportStatus,
|
|
42
|
+
note: row.note,
|
|
43
|
+
})),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderSupportMatrix(rows = supportMatrix()): string {
|
|
48
|
+
const header = [
|
|
49
|
+
'Role',
|
|
50
|
+
'Platform',
|
|
51
|
+
'Versions',
|
|
52
|
+
'Architectures',
|
|
53
|
+
'Status',
|
|
54
|
+
'Note',
|
|
55
|
+
];
|
|
56
|
+
const cells = rows.map((row) => [
|
|
57
|
+
row.role,
|
|
58
|
+
row.platform,
|
|
59
|
+
row.versions,
|
|
60
|
+
row.architectures,
|
|
61
|
+
row.status,
|
|
62
|
+
row.note,
|
|
63
|
+
]);
|
|
64
|
+
const widths = header.map((title, index) =>
|
|
65
|
+
Math.max(title.length, ...cells.map((line) => line[index]!.length))
|
|
66
|
+
);
|
|
67
|
+
const line = (values: string[]) =>
|
|
68
|
+
`| ${values.map((value, index) => value.padEnd(widths[index]!)).join(' | ')} |`;
|
|
69
|
+
return [
|
|
70
|
+
line(header),
|
|
71
|
+
`| ${widths.map((width) => '-'.repeat(width)).join(' | ')} |`,
|
|
72
|
+
...cells.map(line),
|
|
73
|
+
].join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (import.meta.main) process.stdout.write(`${renderSupportMatrix()}\n`);
|
package/src/manager-sync.ts
CHANGED
|
@@ -16,10 +16,9 @@ import { sshArgs, sshFailure } from './ssh';
|
|
|
16
16
|
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
17
17
|
import { publicHubUrl } from './monitoring/beszel';
|
|
18
18
|
import {
|
|
19
|
-
|
|
19
|
+
evaluateEnvironment,
|
|
20
20
|
evaluatePlatform,
|
|
21
21
|
PLATFORM_PROBE,
|
|
22
|
-
type MachinePlatform,
|
|
23
22
|
} from './machine/platform';
|
|
24
23
|
|
|
25
24
|
export interface ManagerNode extends NodeReport {
|
|
@@ -51,14 +50,14 @@ export type DeployNode = (
|
|
|
51
50
|
/** Minimal read-only POSIX inspection requires no workspace, Node, Bun or Git. */
|
|
52
51
|
export async function inspectBootstrap(
|
|
53
52
|
node: FleetNode,
|
|
54
|
-
run: Runner
|
|
55
|
-
os: MachinePlatform = 'linux'
|
|
53
|
+
run: Runner
|
|
56
54
|
): Promise<Check[]> {
|
|
57
55
|
const result = await run([...sshArgs(node), PLATFORM_PROBE], {
|
|
58
56
|
timeoutMs: 20_000,
|
|
59
57
|
});
|
|
60
58
|
if (!result.ok) return [sshFailure(result)];
|
|
61
|
-
const baseline = evaluatePlatform(
|
|
59
|
+
const baseline = evaluatePlatform(result.stdout);
|
|
60
|
+
const environment = evaluateEnvironment(result.stdout);
|
|
62
61
|
return [
|
|
63
62
|
{
|
|
64
63
|
id: 'ssh:fresh-session',
|
|
@@ -75,6 +74,15 @@ export async function inspectBootstrap(
|
|
|
75
74
|
'Review the release/architecture migration in source. No operating-system upgrade or downgrade is performed.',
|
|
76
75
|
},
|
|
77
76
|
},
|
|
77
|
+
{
|
|
78
|
+
id: environment.id,
|
|
79
|
+
status: environment.supported ? 'pass' : 'fail',
|
|
80
|
+
summary: environment.summary,
|
|
81
|
+
remedy: {
|
|
82
|
+
action:
|
|
83
|
+
'Run the worker on a physical or virtual machine of its own; docs/support-matrix.md lists what is supported.',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
78
86
|
];
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -124,7 +132,7 @@ export async function synchronizeManager(
|
|
|
124
132
|
// worker is then verified against.
|
|
125
133
|
if (managerRun) await managerRun;
|
|
126
134
|
const workers = await Promise.all(
|
|
127
|
-
input.definition.workers.
|
|
135
|
+
input.definition.workers.map((machine) => {
|
|
128
136
|
input.progress?.(
|
|
129
137
|
machine.id,
|
|
130
138
|
'Inspecting enrolled identity and fresh SSH access'
|
|
@@ -329,7 +337,7 @@ async function inspectManagerNode(
|
|
|
329
337
|
},
|
|
330
338
|
});
|
|
331
339
|
else {
|
|
332
|
-
checks.push(...(await inspectBootstrap(node, run
|
|
340
|
+
checks.push(...(await inspectBootstrap(node, run)));
|
|
333
341
|
checks.push(...identityChecks(node, peer));
|
|
334
342
|
if (peer) checks.push(keyExpiryCheck(peer));
|
|
335
343
|
}
|
|
@@ -66,7 +66,7 @@ export async function convergeLinux(
|
|
|
66
66
|
checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
|
|
67
67
|
checks.push(
|
|
68
68
|
...(await attempt('workstation:configuration', () =>
|
|
69
|
-
configureWorkstation(root, run)
|
|
69
|
+
configureWorkstation(root, run, node)
|
|
70
70
|
))
|
|
71
71
|
);
|
|
72
72
|
checks.push(
|
|
@@ -169,7 +169,7 @@ async function applyBaseline(
|
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
name: 'ubuntu-baseline',
|
|
172
|
-
sources: [ubuntu],
|
|
172
|
+
sources: [ubuntu, resolve(root, 'ansible/github')],
|
|
173
173
|
force,
|
|
174
174
|
verify: () => baselineServicesActive(run),
|
|
175
175
|
}
|
|
@@ -207,6 +207,7 @@ async function baselineServicesActive(run: Runner): Promise<boolean> {
|
|
|
207
207
|
'is-active',
|
|
208
208
|
'--quiet',
|
|
209
209
|
'personal-worker-auth.timer',
|
|
210
|
+
'personal-worker-github.timer',
|
|
210
211
|
]);
|
|
211
212
|
return user.ok;
|
|
212
213
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fingerprint, writeReceipt } from './fingerprint';
|
|
2
2
|
import { configureWorkspace } from './configure';
|
|
3
3
|
import { stableWorkspace } from './platform/t3-policy';
|
|
4
|
+
import { convergeGithub } from './github/converge';
|
|
4
5
|
import type { Check, Runner } from './types';
|
|
5
6
|
|
|
6
7
|
/** Phase wrapper: one failure code per phase, durations measured, no exception text. */
|
|
@@ -30,11 +31,13 @@ export async function attempt(
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** The `devfleet` launcher (linked to the applied revision, never copied) and
|
|
33
|
-
* this worker's own credential refresh
|
|
34
|
+
* this worker's own credential refresh: its Vault session and, apart from it,
|
|
35
|
+
* its GitHub sign-in. Identical on Linux and macOS.
|
|
34
36
|
*/
|
|
35
37
|
export async function configureWorkstation(
|
|
36
38
|
root: string,
|
|
37
|
-
run: Runner
|
|
39
|
+
run: Runner,
|
|
40
|
+
node?: string
|
|
38
41
|
): Promise<Check[]> {
|
|
39
42
|
const installed = await run(
|
|
40
43
|
// The stable path of the applied revision: it survives pruning.
|
|
@@ -62,6 +65,7 @@ export async function configureWorkstation(
|
|
|
62
65
|
summary:
|
|
63
66
|
'The devfleet launcher is linked to the applied revision; authentication remains a separate gate.',
|
|
64
67
|
},
|
|
68
|
+
await convergeGithub(root, run, node),
|
|
65
69
|
];
|
|
66
70
|
}
|
|
67
71
|
|
package/src/node-converge.ts
CHANGED
|
@@ -23,7 +23,7 @@ async function main(): Promise<Check[]> {
|
|
|
23
23
|
summary: 'PLATFORM_UNSUPPORTED',
|
|
24
24
|
remedy: {
|
|
25
25
|
action:
|
|
26
|
-
'Workers
|
|
26
|
+
'Workers run an Ubuntu release docs/support-matrix.md lists. The macOS manager converges itself locally in fleet-sync.',
|
|
27
27
|
},
|
|
28
28
|
},
|
|
29
29
|
];
|
|
@@ -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
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { record } from './config';
|
|
24
24
|
import { installedDefinition, type FleetDefinition } from './definition';
|
|
25
25
|
import { cleanCheck, validCheck } from './protocol';
|
|
26
|
+
import { UBUNTU_RELEASES } from './platform/ubuntu-release';
|
|
26
27
|
|
|
27
28
|
export interface BootstrapPayload {
|
|
28
29
|
revision: string;
|
|
@@ -127,6 +128,7 @@ export async function bootstrapRemote(
|
|
|
127
128
|
payload.digest,
|
|
128
129
|
node.id,
|
|
129
130
|
transfer.mode,
|
|
131
|
+
UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
|
|
130
132
|
]
|
|
131
133
|
.map(shellQuote)
|
|
132
134
|
.join(' ');
|
package/src/repair-catalog.ts
CHANGED
|
@@ -141,8 +141,8 @@ export const REPAIR_RECIPES: RepairRecipe[] = [
|
|
|
141
141
|
login(
|
|
142
142
|
'copilot',
|
|
143
143
|
'Sign in to Copilot',
|
|
144
|
-
[
|
|
145
|
-
'
|
|
144
|
+
[],
|
|
145
|
+
'Copilot CLI uses the node’s GitHub sign-in, which fleet-sync brings from the stored credential (github-token). Run fleet-sync; store a fine-grained token with the Copilot Requests permission if auth:copilot says the token kind is refused. No per-node copilot login.'
|
|
146
146
|
),
|
|
147
147
|
login(
|
|
148
148
|
'gemini',
|
package/src/secrets.ts
CHANGED
|
@@ -12,10 +12,13 @@ export const SECRET_NAMES = [
|
|
|
12
12
|
'monitoring-tunnel-token',
|
|
13
13
|
'proxy-tunnel-token',
|
|
14
14
|
] as const;
|
|
15
|
-
/** Secrets
|
|
16
|
-
*
|
|
15
|
+
/** Secrets a fleet may leave undeclared. A Vault fleet that names no location
|
|
16
|
+
* for the GitHub token keeps it where dev-fleet always read it (see
|
|
17
|
+
* `vaultLocation`); the others are needed only by a feature the fleet
|
|
18
|
+
* declares, and are otherwise unavailable.
|
|
17
19
|
*/
|
|
18
20
|
export const OPTIONAL_SECRET_NAMES = [
|
|
21
|
+
'github-token',
|
|
19
22
|
// The off-machine storage of the monitoring hub's backups
|
|
20
23
|
// (services.monitoring.backups).
|
|
21
24
|
'monitoring-backup-access-key',
|
|
@@ -52,16 +55,53 @@ export type SecretCommand = (
|
|
|
52
55
|
) => Promise<string | null>;
|
|
53
56
|
|
|
54
57
|
/** One read of one secret for this node. Null when it is not available; the
|
|
55
|
-
* value is never logged, printed or placed in argv.
|
|
58
|
+
* value is never logged, printed or placed in argv. `node` names a node's own
|
|
59
|
+
* copy of a secret that is kept once per node (`secretVariant`).
|
|
56
60
|
*/
|
|
57
|
-
export type SecretsProvider = (
|
|
61
|
+
export type SecretsProvider = (
|
|
62
|
+
name: SecretName,
|
|
63
|
+
node?: string
|
|
64
|
+
) => Promise<string | null>;
|
|
58
65
|
|
|
59
66
|
const present = (value: string | null | undefined): string | null =>
|
|
60
67
|
value?.trim() ? value.trim() : null;
|
|
61
68
|
|
|
62
69
|
/** `proxy-client-key` is read from `DEVFLEET_SECRET_PROXY_CLIENT_KEY`. */
|
|
63
|
-
export const envName = (name: SecretName): string =>
|
|
64
|
-
`DEVFLEET_SECRET_${name.toUpperCase().replaceAll('-', '_')}`;
|
|
70
|
+
export const envName = (name: SecretName, node?: string): string =>
|
|
71
|
+
`DEVFLEET_SECRET_${secretVariant(name, node).toUpperCase().replaceAll('-', '_')}`;
|
|
72
|
+
|
|
73
|
+
/** The name of a node's own copy of a secret: `github-token` of `worker-one`
|
|
74
|
+
* is `github-token-worker-one` (`DEVFLEET_SECRET_GITHUB_TOKEN_WORKER_ONE`, the
|
|
75
|
+
* file or sops key `github-token-worker-one`, the Vault field `FIELD_WORKER_ONE`).
|
|
76
|
+
*/
|
|
77
|
+
export const secretVariant = (name: SecretName, node?: string): string =>
|
|
78
|
+
node ? `${name}-${node}` : name;
|
|
79
|
+
|
|
80
|
+
/** Where a Vault fleet keeps a secret. The GitHub token was always read from
|
|
81
|
+
* the field `GITHUB_TOKEN` beside the proxy client key; a fleet that names no
|
|
82
|
+
* location of its own keeps it there.
|
|
83
|
+
*/
|
|
84
|
+
export function vaultLocation(
|
|
85
|
+
config: Extract<SecretsConfig, { provider: 'vault' }>,
|
|
86
|
+
name: SecretName,
|
|
87
|
+
node?: string
|
|
88
|
+
): VaultLocation | null {
|
|
89
|
+
const location =
|
|
90
|
+
config.locations[name as keyof typeof config.locations] ??
|
|
91
|
+
(name === 'github-token'
|
|
92
|
+
? {
|
|
93
|
+
path: config.locations['proxy-client-key'].path,
|
|
94
|
+
field: 'GITHUB_TOKEN',
|
|
95
|
+
}
|
|
96
|
+
: null);
|
|
97
|
+
if (!location) return null;
|
|
98
|
+
return node
|
|
99
|
+
? {
|
|
100
|
+
path: location.path,
|
|
101
|
+
field: `${location.field}_${node.toUpperCase().replaceAll('-', '_')}`,
|
|
102
|
+
}
|
|
103
|
+
: location;
|
|
104
|
+
}
|
|
65
105
|
|
|
66
106
|
export function secretsProvider(
|
|
67
107
|
config: SecretsConfig,
|
|
@@ -74,29 +114,34 @@ export function secretsProvider(
|
|
|
74
114
|
const env = options.env ?? process.env;
|
|
75
115
|
const run = options.run ?? execute;
|
|
76
116
|
if (config.provider === 'env')
|
|
77
|
-
return async (name) => present(env[envName(name)]);
|
|
117
|
+
return async (name, node) => present(env[envName(name, node)]);
|
|
78
118
|
if (config.provider === 'file') {
|
|
79
119
|
// One private file per secret, named after it.
|
|
80
120
|
const directory =
|
|
81
121
|
config.directory ??
|
|
82
122
|
join(options.home ?? homedir(), '.config/dev-fleet/secrets');
|
|
83
|
-
return async (name) =>
|
|
84
|
-
present(
|
|
123
|
+
return async (name, node) =>
|
|
124
|
+
present(
|
|
125
|
+
await readFile(
|
|
126
|
+
join(directory, secretVariant(name, node)),
|
|
127
|
+
'utf8'
|
|
128
|
+
).catch(() => null)
|
|
129
|
+
);
|
|
85
130
|
}
|
|
86
131
|
if (config.provider === 'sops')
|
|
87
132
|
// An age- (or KMS-) encrypted document with one top-level key per secret.
|
|
88
|
-
return async (name) =>
|
|
133
|
+
return async (name, node) =>
|
|
89
134
|
present(
|
|
90
135
|
await run([
|
|
91
136
|
'sops',
|
|
92
137
|
'--decrypt',
|
|
93
138
|
'--extract',
|
|
94
|
-
`["${name}"]`,
|
|
139
|
+
`["${secretVariant(name, node)}"]`,
|
|
95
140
|
config.file,
|
|
96
141
|
])
|
|
97
142
|
);
|
|
98
|
-
return async (name) => {
|
|
99
|
-
const location = config
|
|
143
|
+
return async (name, node) => {
|
|
144
|
+
const location = vaultLocation(config, name, node);
|
|
100
145
|
if (!location) return null;
|
|
101
146
|
return present(
|
|
102
147
|
await run([
|
package/src/service-auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { record } from './config';
|
|
2
|
+
import { copilotCheck } from './github/copilot';
|
|
2
3
|
import type { Check, CommandResult, Runner } from './types';
|
|
3
4
|
|
|
4
5
|
interface Service {
|
|
@@ -48,8 +49,6 @@ export async function codingAuth(
|
|
|
48
49
|
export const PROXY_ROUTED: Record<string, string> = {
|
|
49
50
|
gemini:
|
|
50
51
|
'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
|
|
51
|
-
copilot:
|
|
52
|
-
'copilot is not routed through the CLI proxy and dev-fleet does not manage its GitHub sign-in; no provider account is checked.',
|
|
53
52
|
opencode:
|
|
54
53
|
'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
|
|
55
54
|
};
|
|
@@ -101,6 +100,7 @@ export async function serviceAuth(run: Runner): Promise<Check[]> {
|
|
|
101
100
|
];
|
|
102
101
|
const checks: Check[] = [];
|
|
103
102
|
for (const service of services) checks.push(await serviceCheck(service, run));
|
|
103
|
+
checks.push(await copilotCheck(run));
|
|
104
104
|
for (const [id, reason] of Object.entries(PROXY_ROUTED))
|
|
105
105
|
checks.push({
|
|
106
106
|
id: `auth:${id}`,
|
|
@@ -4,29 +4,79 @@ import { secretsProvider } from './secrets';
|
|
|
4
4
|
import { registry } from './agents/registry';
|
|
5
5
|
import { resolveVaultConfig, usesVault } from './vault-config';
|
|
6
6
|
import { record } from './config';
|
|
7
|
+
import { githubCredential } from './github/credential';
|
|
8
|
+
import { hostId } from './topology';
|
|
9
|
+
import { hostname } from 'node:os';
|
|
10
|
+
import { githubSignInState } from './github/sync';
|
|
7
11
|
|
|
8
12
|
/** Inject every subprocess; never put credentials in argv, reports, or logs. */
|
|
9
|
-
export async function workerAccessChecks(
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
export async function workerAccessChecks(
|
|
14
|
+
run: Runner,
|
|
15
|
+
nodeId?: string
|
|
16
|
+
): Promise<Check[]> {
|
|
12
17
|
return [
|
|
13
|
-
|
|
14
|
-
id: 'worker:github-principal',
|
|
15
|
-
status:
|
|
16
|
-
github.ok && github.stdout.trim() === policy.githubLogin
|
|
17
|
-
? 'pass'
|
|
18
|
-
: 'fail',
|
|
19
|
-
summary:
|
|
20
|
-
'Worker GitHub authentication must match the declared common account.',
|
|
21
|
-
remedy: {
|
|
22
|
-
action:
|
|
23
|
-
'Authenticate this worker with the declared GitHub account. Credentials stay on their original machines.',
|
|
24
|
-
},
|
|
25
|
-
},
|
|
18
|
+
await githubSync(run, nodeId),
|
|
26
19
|
await proxyAuthentication(run),
|
|
27
20
|
...(usesVault() ? [await vaultRenewalCheck(run)] : []),
|
|
28
21
|
];
|
|
29
22
|
}
|
|
23
|
+
|
|
24
|
+
const GITHUB_REMEDIES: Record<string, string> = {
|
|
25
|
+
GITHUB_TOKEN_MISSING:
|
|
26
|
+
'The secrets provider holds no GitHub token for this worker. Store it under github-token (github-token-<node> with github.credential "per-node"); the next sign-in job or fleet-sync signs the worker in.',
|
|
27
|
+
GITHUB_TOKEN_REJECTED:
|
|
28
|
+
'GitHub refuses the stored token: it was revoked or has expired. Store a new one under github-token; nothing about the secret store itself needs fixing.',
|
|
29
|
+
GITHUB_LOGIN_MISMATCH:
|
|
30
|
+
'The stored token belongs to another account than worker.githubLogin. Store the fleet login’s token, or correct worker.githubLogin in the fleet configuration.',
|
|
31
|
+
GITHUB_UNREACHABLE:
|
|
32
|
+
'GitHub did not answer from this worker. Check its network and DNS; the worker keeps the sign-in it has.',
|
|
33
|
+
GITHUB_SIGN_IN_FAILED:
|
|
34
|
+
'The stored token is valid but this worker has not adopted it. Inspect the job with: journalctl --user -u personal-worker-github.service, or run fleet-sync.',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** GitHub sign-in is measured on its own, so a GitHub fault is never reported
|
|
38
|
+
* as a fault of the secret store's renewal, and the code names the system to
|
|
39
|
+
* fix.
|
|
40
|
+
*/
|
|
41
|
+
async function githubSync(run: Runner, nodeId?: string): Promise<Check> {
|
|
42
|
+
const login = fleet.worker?.githubLogin;
|
|
43
|
+
if (!login)
|
|
44
|
+
return {
|
|
45
|
+
id: 'worker:github-sync',
|
|
46
|
+
status: 'fail',
|
|
47
|
+
summary: 'GITHUB_LOGIN_UNDECLARED',
|
|
48
|
+
remedy: {
|
|
49
|
+
action: 'Declare the fleet GitHub login as worker.githubLogin.',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const code = await githubSignInState({
|
|
53
|
+
run,
|
|
54
|
+
login,
|
|
55
|
+
token: githubCredential(fleet, nodeId ?? hostId(hostname()), secrets(run)),
|
|
56
|
+
});
|
|
57
|
+
return code === 'GITHUB_OK'
|
|
58
|
+
? {
|
|
59
|
+
id: 'worker:github-sync',
|
|
60
|
+
status: 'pass',
|
|
61
|
+
summary:
|
|
62
|
+
'Worker GitHub authentication matches the declared common account.',
|
|
63
|
+
}
|
|
64
|
+
: {
|
|
65
|
+
id: 'worker:github-sync',
|
|
66
|
+
status: 'fail',
|
|
67
|
+
summary: code,
|
|
68
|
+
remedy: { action: GITHUB_REMEDIES[code]! },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const secrets = (run: Runner) =>
|
|
73
|
+
secretsProvider(fleet.secrets, {
|
|
74
|
+
run: async (argv) => {
|
|
75
|
+
const result = await run(argv, { timeoutMs: 20_000 });
|
|
76
|
+
return result.ok ? result.stdout : null;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
30
80
|
/** Vault refuses a read the token's policy does not allow with a 403. */
|
|
31
81
|
const PERMISSION_DENIED = /permission denied|Code: 403/i;
|
|
32
82
|
|