@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
|
@@ -203,7 +203,7 @@ function deployWithMonitoring(
|
|
|
203
203
|
);
|
|
204
204
|
// Workers are Linux by definition.
|
|
205
205
|
ui.progress.step(node.id, 'Verifying SSH, OS and T3');
|
|
206
|
-
result.checks.push(...(await inspectBootstrap(node, run
|
|
206
|
+
result.checks.push(...(await inspectBootstrap(node, run)));
|
|
207
207
|
result.checks.push(
|
|
208
208
|
...(await workerChecks(enabled(idesOf(canonical), IDES), node, {
|
|
209
209
|
run,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { FleetUsageError } from '../errors';
|
|
4
|
+
import { fleet } from '../fleet-config';
|
|
5
|
+
import { githubPolicy } from '../github/policy';
|
|
6
|
+
import { verifyGithubWorkflow } from '../github/workflow';
|
|
7
|
+
import { runCommand } from '../runner';
|
|
8
|
+
import { hostId } from '../topology';
|
|
9
|
+
import type { Runner } from '../types';
|
|
10
|
+
import type { CommandOutcome } from './contracts';
|
|
11
|
+
|
|
12
|
+
/** Runs the GitHub workflow an agent depends on from this machine against
|
|
13
|
+
* the declared sandbox repository, and cleans up after itself.
|
|
14
|
+
*/
|
|
15
|
+
export async function githubVerify(
|
|
16
|
+
run: Runner = runCommand,
|
|
17
|
+
node = hostId(hostname())
|
|
18
|
+
): Promise<CommandOutcome> {
|
|
19
|
+
const sandbox = githubPolicy(fleet).sandbox;
|
|
20
|
+
if (!sandbox)
|
|
21
|
+
throw new FleetUsageError(
|
|
22
|
+
'No sandbox repository. Declare github.sandbox ("owner/name") in fleet.json: a repository the fleet login may branch, push to and open pull requests on.'
|
|
23
|
+
);
|
|
24
|
+
const result = await verifyGithubWorkflow({ run, sandbox, node });
|
|
25
|
+
const ok = result.check.status === 'pass';
|
|
26
|
+
return {
|
|
27
|
+
exitCode: ok ? 0 : 1,
|
|
28
|
+
data: {
|
|
29
|
+
version: 1,
|
|
30
|
+
command: 'github-verify',
|
|
31
|
+
ok,
|
|
32
|
+
node,
|
|
33
|
+
checks: [result.check],
|
|
34
|
+
...(result.failed ? { failedStep: result.failed } : {}),
|
|
35
|
+
leftBehind: result.leftBehind,
|
|
36
|
+
},
|
|
37
|
+
text: `${result.check.id} ${result.check.status}: ${result.check.summary}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerGithubVerify(
|
|
42
|
+
program: Command,
|
|
43
|
+
complete: (outcome: CommandOutcome) => void
|
|
44
|
+
): void {
|
|
45
|
+
program
|
|
46
|
+
.command('fleet-github-verify')
|
|
47
|
+
.description(
|
|
48
|
+
'Prove this machine can clone, push, open a pull request, read checks and comment on GitHub'
|
|
49
|
+
)
|
|
50
|
+
.addHelpText(
|
|
51
|
+
'after',
|
|
52
|
+
'\nWorks on the sandbox repository named by github.sandbox in fleet.json: a uniquely named branch and a draft pull request, both closed and deleted again by the same run, whatever failed.'
|
|
53
|
+
)
|
|
54
|
+
.action(async () => {
|
|
55
|
+
complete(await githubVerify());
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -11,6 +11,7 @@ import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
|
|
|
11
11
|
import { fleetReport, type FleetReportOptions } from './fleet-report';
|
|
12
12
|
import { fleetWatch, watchServices } from './fleet-watch';
|
|
13
13
|
import type { NodeReport } from '../types';
|
|
14
|
+
import { registerGithubVerify } from './github-verify';
|
|
14
15
|
|
|
15
16
|
type Complete = (outcome: CommandOutcome) => void;
|
|
16
17
|
|
|
@@ -350,6 +351,7 @@ export function registerFleet(
|
|
|
350
351
|
registerRepair(registration);
|
|
351
352
|
registerNodes(registration);
|
|
352
353
|
registerAccounts(registration);
|
|
354
|
+
registerGithubVerify(program, complete);
|
|
353
355
|
}
|
|
354
356
|
|
|
355
357
|
function registerAccounts({ program, defaults, complete }: Registration) {
|
package/src/cli/registry.ts
CHANGED
|
@@ -161,6 +161,13 @@ export const COMMANDS: CommandSpec[] = [
|
|
|
161
161
|
['accounts', 'login', 'relogin', 'remove', 'add', 'sign in'],
|
|
162
162
|
true,
|
|
163
163
|
],
|
|
164
|
+
[
|
|
165
|
+
'fleet-github-verify',
|
|
166
|
+
'Verify the GitHub workflow from this machine',
|
|
167
|
+
'Against the sandbox repository fleet.json declares (github.sandbox): clone, push a uniquely named branch, open a draft pull request, read its checks and comment, then close it and delete the branch whatever failed. Fails at the named step.',
|
|
168
|
+
['github', 'verify', 'pull request', 'push', 'permissions', 'workflow'],
|
|
169
|
+
true,
|
|
170
|
+
],
|
|
164
171
|
]),
|
|
165
172
|
...group('T3', [
|
|
166
173
|
[
|
package/src/definition.ts
CHANGED
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
type T3Definition,
|
|
18
18
|
} from './ide/definition';
|
|
19
19
|
import { parseNotify, type NotifyConfig } from './notify';
|
|
20
|
+
import { ubuntuRelease } from './platform/ubuntu-release';
|
|
21
|
+
import { parseGithubPolicy, type GithubPolicy } from './github/policy';
|
|
20
22
|
|
|
21
23
|
export interface MachineDefinition {
|
|
22
24
|
id: string;
|
|
23
25
|
ssh: string;
|
|
24
|
-
os: 'linux'
|
|
26
|
+
os: 'linux';
|
|
25
27
|
/** Tool profiles this worker adds to the fleet-wide ones. */
|
|
26
28
|
toolProfiles?: ToolProfile[];
|
|
27
29
|
network?: {
|
|
@@ -78,7 +80,11 @@ export interface FleetDefinition {
|
|
|
78
80
|
git?: GitIdentity;
|
|
79
81
|
/** Where fleet-watch sends notifications; absent, nobody is notified. */
|
|
80
82
|
notify?: NotifyConfig;
|
|
81
|
-
|
|
83
|
+
/** An Ubuntu release ansible/ubuntu/policy.json accepts, with exactly the
|
|
84
|
+
* architectures the policy names for it. */
|
|
85
|
+
ubuntu: { release: string; architectures: string[] };
|
|
86
|
+
/** How every node signs in to GitHub; defaults when absent. */
|
|
87
|
+
github?: GithubPolicy;
|
|
82
88
|
}
|
|
83
89
|
/** How provider accounts are watched and chosen (src/accounts/). */
|
|
84
90
|
export interface AccountPolicy {
|
|
@@ -223,6 +229,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
223
229
|
't3',
|
|
224
230
|
'git',
|
|
225
231
|
'notify',
|
|
232
|
+
'github',
|
|
226
233
|
])
|
|
227
234
|
)
|
|
228
235
|
return fail();
|
|
@@ -249,7 +256,10 @@ export function parseDefinition(value: unknown): FleetDefinition {
|
|
|
249
256
|
*/
|
|
250
257
|
function optionalSections(
|
|
251
258
|
value: Record<string, unknown>
|
|
252
|
-
): Pick<
|
|
259
|
+
): Pick<
|
|
260
|
+
FleetDefinition,
|
|
261
|
+
'worker' | 'ides' | 't3' | 'git' | 'notify' | 'github'
|
|
262
|
+
> {
|
|
253
263
|
return {
|
|
254
264
|
...(value['worker'] === undefined
|
|
255
265
|
? {}
|
|
@@ -260,6 +270,9 @@ function optionalSections(
|
|
|
260
270
|
...(value['notify'] === undefined
|
|
261
271
|
? {}
|
|
262
272
|
: { notify: parseNotify(value['notify']) ?? fail() }),
|
|
273
|
+
...(value['github'] === undefined
|
|
274
|
+
? {}
|
|
275
|
+
: { github: parseGithubPolicy(value['github']) }),
|
|
263
276
|
};
|
|
264
277
|
}
|
|
265
278
|
/** Singleton services name no host: they always run on the manager. */
|
|
@@ -449,14 +462,22 @@ function source(value: unknown): FleetDefinition['source'] {
|
|
|
449
462
|
return { repository: value['repository'] };
|
|
450
463
|
}
|
|
451
464
|
function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
|
|
465
|
+
if (!record(ubuntu) || !keys(ubuntu, ['release', 'architectures']))
|
|
466
|
+
return fail();
|
|
467
|
+
const accepted =
|
|
468
|
+
typeof ubuntu['release'] === 'string'
|
|
469
|
+
? ubuntuRelease(ubuntu['release'])
|
|
470
|
+
: undefined;
|
|
452
471
|
if (
|
|
453
|
-
!
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
JSON.stringify(ubuntu['architectures']) !== '["amd64","arm64"]'
|
|
472
|
+
!accepted ||
|
|
473
|
+
JSON.stringify(ubuntu['architectures']) !==
|
|
474
|
+
JSON.stringify(accepted.architectures)
|
|
457
475
|
)
|
|
458
476
|
return fail();
|
|
459
|
-
return
|
|
477
|
+
return {
|
|
478
|
+
release: accepted.release,
|
|
479
|
+
architectures: [...accepted.architectures],
|
|
480
|
+
};
|
|
460
481
|
}
|
|
461
482
|
|
|
462
483
|
/** The configuration as every machine keeps it at
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { FleetDefinition } from '../definition';
|
|
2
|
+
import type { SecretsProvider } from '../secrets';
|
|
3
|
+
import type { Check, Runner } from '../types';
|
|
4
|
+
import { githubCredential } from './credential';
|
|
5
|
+
import { githubNode, githubPolicy, type GithubNode } from './policy';
|
|
6
|
+
import { activeToken, GITHUB_STEP_TIMEOUT_MS } from './sync';
|
|
7
|
+
|
|
8
|
+
export interface GithubCheckInput {
|
|
9
|
+
definition: FleetDefinition;
|
|
10
|
+
/** The node measured; the manager when it is not a declared worker. */
|
|
11
|
+
node?: string;
|
|
12
|
+
secret: SecretsProvider;
|
|
13
|
+
now?: () => Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DAY = 86_400_000;
|
|
17
|
+
const EXPIRY_WARN_DAYS = 14;
|
|
18
|
+
const EXPIRY_FAIL_DAYS = 3;
|
|
19
|
+
const RATE_WARN_FRACTION = 0.2;
|
|
20
|
+
|
|
21
|
+
/** GitHub sign-in on any node, manager or worker: what the token is, what it
|
|
22
|
+
* can do and for how long. One request to GitHub answers the scopes, the
|
|
23
|
+
* expiry and the rate limit together; nothing it prints is kept.
|
|
24
|
+
*/
|
|
25
|
+
export async function githubChecks(
|
|
26
|
+
run: Runner,
|
|
27
|
+
input: GithubCheckInput
|
|
28
|
+
): Promise<Check[]> {
|
|
29
|
+
const target = githubNode(input.definition, input.node);
|
|
30
|
+
const policy = githubPolicy(input.definition);
|
|
31
|
+
const status = await run(['gh', 'auth', 'status', '--active'], {
|
|
32
|
+
timeoutMs: 15_000,
|
|
33
|
+
});
|
|
34
|
+
const checks: Check[] = [
|
|
35
|
+
{
|
|
36
|
+
id: 'auth:github',
|
|
37
|
+
status: status.ok ? 'pass' : 'fail',
|
|
38
|
+
summary: status.ok
|
|
39
|
+
? 'github check succeeded in this process context.'
|
|
40
|
+
: `github check failed (${status.reason}); no credential output retained.`,
|
|
41
|
+
...(status.ok
|
|
42
|
+
? {}
|
|
43
|
+
: {
|
|
44
|
+
remedy: {
|
|
45
|
+
action: target.managed
|
|
46
|
+
? 'Put the fleet GitHub token in the secrets provider under github-token, then run fleet-sync: it signs this node in. No interactive login is needed.'
|
|
47
|
+
: 'Authenticate on this node with gh auth login, then gh auth setup-git.',
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
const user = await run(['gh', 'api', '--include', 'user'], {
|
|
53
|
+
timeoutMs: GITHUB_STEP_TIMEOUT_MS,
|
|
54
|
+
});
|
|
55
|
+
const response = parseResponse(user.stdout);
|
|
56
|
+
const login =
|
|
57
|
+
user.ok && typeof response.body?.['login'] === 'string'
|
|
58
|
+
? response.body['login']
|
|
59
|
+
: null;
|
|
60
|
+
checks.push(principal(target, login));
|
|
61
|
+
if (target.managed) checks.push(await tokenCurrent(run, target, input));
|
|
62
|
+
if (login === null) return checks;
|
|
63
|
+
const now = (input.now ?? (() => new Date()))();
|
|
64
|
+
checks.push(
|
|
65
|
+
await permissions(run, policy.scopes, policy.repositories, response),
|
|
66
|
+
expiry(response.headers, now),
|
|
67
|
+
rateLimit(response.headers),
|
|
68
|
+
...(await organizationAccess(run, policy.organizations, login)),
|
|
69
|
+
await gitCredential(run)
|
|
70
|
+
);
|
|
71
|
+
return checks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Response {
|
|
75
|
+
headers: Record<string, string>;
|
|
76
|
+
body: Record<string, unknown> | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `gh api --include`: a status line, headers, a blank line, then the body. */
|
|
80
|
+
export function parseResponse(text: string): Response {
|
|
81
|
+
const [head = '', ...rest] = text.split(/\r?\n\r?\n/);
|
|
82
|
+
const headers: Record<string, string> = {};
|
|
83
|
+
for (const line of head.split(/\r?\n/).slice(1)) {
|
|
84
|
+
const at = line.indexOf(':');
|
|
85
|
+
if (at > 0)
|
|
86
|
+
headers[line.slice(0, at).trim().toLowerCase()] = line
|
|
87
|
+
.slice(at + 1)
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
let body: Record<string, unknown> | null = null;
|
|
91
|
+
try {
|
|
92
|
+
const parsed: unknown = JSON.parse(rest.join('\n\n'));
|
|
93
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
94
|
+
body = parsed as Record<string, unknown>;
|
|
95
|
+
} catch {}
|
|
96
|
+
return { headers, body };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function principal(target: GithubNode, login: string | null): Check {
|
|
100
|
+
if (target.login === null)
|
|
101
|
+
return {
|
|
102
|
+
id: 'github:principal',
|
|
103
|
+
status: 'skip',
|
|
104
|
+
required: false,
|
|
105
|
+
summary:
|
|
106
|
+
'The fleet declares no GitHub login (worker.githubLogin); the account is not compared.',
|
|
107
|
+
};
|
|
108
|
+
const ok = login === target.login;
|
|
109
|
+
return {
|
|
110
|
+
id: 'github:principal',
|
|
111
|
+
status: ok ? 'pass' : 'fail',
|
|
112
|
+
summary: ok
|
|
113
|
+
? `This ${target.role} is signed in to GitHub as the declared ${target.managed ? 'fleet' : 'personal'} login.`
|
|
114
|
+
: login === null
|
|
115
|
+
? 'GITHUB_NOT_SIGNED_IN'
|
|
116
|
+
: 'GITHUB_LOGIN_MISMATCH: this node is signed in to GitHub as an account the fleet configuration does not declare for it.',
|
|
117
|
+
...(ok
|
|
118
|
+
? {}
|
|
119
|
+
: {
|
|
120
|
+
remedy: {
|
|
121
|
+
action: target.managed
|
|
122
|
+
? 'Run fleet-sync: it signs this node in with the token in the secrets provider (github-token). If the stored token belongs to another account, replace it or change worker.githubLogin.'
|
|
123
|
+
: 'Sign in on this manager as the login named in github.manager of the fleet configuration (gh auth login), or change that declaration. dev-fleet never replaces a personal login.',
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The node uses the token the store holds now: after a rotation, every node
|
|
130
|
+
* passes before the old token is revoked.
|
|
131
|
+
*/
|
|
132
|
+
async function tokenCurrent(
|
|
133
|
+
run: Runner,
|
|
134
|
+
target: GithubNode,
|
|
135
|
+
input: GithubCheckInput
|
|
136
|
+
): Promise<Check> {
|
|
137
|
+
const stored = (
|
|
138
|
+
(await githubCredential(input.definition, target.node, input.secret)()) ??
|
|
139
|
+
''
|
|
140
|
+
).trim();
|
|
141
|
+
if (!stored)
|
|
142
|
+
return {
|
|
143
|
+
id: 'github:token-current',
|
|
144
|
+
status: 'skip',
|
|
145
|
+
required: false,
|
|
146
|
+
summary:
|
|
147
|
+
'The secrets provider holds no github-token for this node; there is no stored token to compare with.',
|
|
148
|
+
};
|
|
149
|
+
const ok = (await activeToken(run)) === stored;
|
|
150
|
+
return {
|
|
151
|
+
id: 'github:token-current',
|
|
152
|
+
status: ok ? 'pass' : 'fail',
|
|
153
|
+
summary: ok
|
|
154
|
+
? 'This node signs GitHub requests with the token the store holds now.'
|
|
155
|
+
: 'GITHUB_TOKEN_STALE: this node still uses a token the store no longer holds.',
|
|
156
|
+
...(ok
|
|
157
|
+
? {}
|
|
158
|
+
: {
|
|
159
|
+
remedy: {
|
|
160
|
+
action:
|
|
161
|
+
'Run fleet-sync, or wait for the next run of the GitHub sign-in job: either adopts the stored token while the old one still works. Revoke the old token only when every node passes this check.',
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A scope a token has implies the scopes beneath it. */
|
|
168
|
+
const IMPLIED: Record<string, string[]> = {
|
|
169
|
+
repo: [
|
|
170
|
+
'repo:status',
|
|
171
|
+
'repo_deployment',
|
|
172
|
+
'public_repo',
|
|
173
|
+
'repo:invite',
|
|
174
|
+
'security_events',
|
|
175
|
+
],
|
|
176
|
+
'write:packages': ['read:packages'],
|
|
177
|
+
'admin:org': ['write:org', 'read:org', 'manage_runners:org'],
|
|
178
|
+
'write:org': ['read:org'],
|
|
179
|
+
'admin:repo_hook': ['write:repo_hook', 'read:repo_hook'],
|
|
180
|
+
'write:repo_hook': ['read:repo_hook'],
|
|
181
|
+
'admin:public_key': ['write:public_key', 'read:public_key'],
|
|
182
|
+
'write:public_key': ['read:public_key'],
|
|
183
|
+
user: ['read:user', 'user:email', 'user:follow'],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
async function permissions(
|
|
187
|
+
run: Runner,
|
|
188
|
+
scopes: string[],
|
|
189
|
+
repositories: string[],
|
|
190
|
+
response: Response
|
|
191
|
+
): Promise<Check> {
|
|
192
|
+
const base = { id: 'github:permissions' };
|
|
193
|
+
if (!scopes.length && !repositories.length)
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
status: 'skip',
|
|
197
|
+
required: false,
|
|
198
|
+
summary:
|
|
199
|
+
'The fleet declares no GitHub scopes or repositories (github.scopes, github.repositories).',
|
|
200
|
+
};
|
|
201
|
+
const granted = response.headers['x-oauth-scopes'];
|
|
202
|
+
const missing: string[] = [];
|
|
203
|
+
if (scopes.length && granted !== undefined) {
|
|
204
|
+
const held = new Set(
|
|
205
|
+
granted
|
|
206
|
+
.split(',')
|
|
207
|
+
.map((scope) => scope.trim())
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.flatMap((scope) => [scope, ...(IMPLIED[scope] ?? [])])
|
|
210
|
+
);
|
|
211
|
+
missing.push(...scopes.filter((scope) => !held.has(scope)));
|
|
212
|
+
}
|
|
213
|
+
for (const repository of repositories) {
|
|
214
|
+
const result = await run(
|
|
215
|
+
['gh', 'api', `repos/${repository}`, '--jq', '.permissions.push'],
|
|
216
|
+
{ timeoutMs: GITHUB_STEP_TIMEOUT_MS }
|
|
217
|
+
);
|
|
218
|
+
if (!result.ok || result.stdout.trim() !== 'true')
|
|
219
|
+
missing.push(`push to ${repository}`);
|
|
220
|
+
}
|
|
221
|
+
if (missing.length)
|
|
222
|
+
return {
|
|
223
|
+
...base,
|
|
224
|
+
status: 'fail',
|
|
225
|
+
summary: `GITHUB_PERMISSION_MISSING: ${missing.join(', ')}`,
|
|
226
|
+
remedy: {
|
|
227
|
+
action:
|
|
228
|
+
'Issue a token with the permissions named, store it as github-token and run fleet-sync. A token without them fails agents mid-task with a 403.',
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
// A fine-grained token reports no scopes: only the repositories are proof.
|
|
232
|
+
if (scopes.length && granted === undefined)
|
|
233
|
+
return {
|
|
234
|
+
...base,
|
|
235
|
+
status: 'warn',
|
|
236
|
+
summary:
|
|
237
|
+
'GITHUB_SCOPES_NOT_OBSERVABLE: the token reports no scopes (a fine-grained token); the declared repositories were checked.',
|
|
238
|
+
};
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
status: 'pass',
|
|
242
|
+
summary:
|
|
243
|
+
'The token holds every declared scope and can push to every declared repository.',
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** `2026-11-20 12:00:00 UTC` or `2026-11-20 12:00:00 -0800`. */
|
|
248
|
+
export function parseExpiry(value: string): Date | null {
|
|
249
|
+
const match =
|
|
250
|
+
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) (UTC|[+-]\d{4})$/.exec(value);
|
|
251
|
+
if (!match) return null;
|
|
252
|
+
const zone =
|
|
253
|
+
match[3] === 'UTC' ? 'Z' : `${match[3]!.slice(0, 3)}:${match[3]!.slice(3)}`;
|
|
254
|
+
const date = new Date(`${match[1]}T${match[2]}${zone}`);
|
|
255
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function expiry(headers: Record<string, string>, now: Date): Check {
|
|
259
|
+
const value = headers['github-authentication-token-expiration'];
|
|
260
|
+
const base = { id: 'github:expiry' };
|
|
261
|
+
if (value === undefined)
|
|
262
|
+
return {
|
|
263
|
+
...base,
|
|
264
|
+
status: 'pass',
|
|
265
|
+
summary: 'GitHub reports no expiry date for this token.',
|
|
266
|
+
};
|
|
267
|
+
const date = parseExpiry(value);
|
|
268
|
+
if (!date)
|
|
269
|
+
return {
|
|
270
|
+
...base,
|
|
271
|
+
status: 'warn',
|
|
272
|
+
summary:
|
|
273
|
+
'GitHub reported an expiry date in a form this check does not read.',
|
|
274
|
+
};
|
|
275
|
+
const days = (date.getTime() - now.getTime()) / DAY;
|
|
276
|
+
const at = date.toISOString();
|
|
277
|
+
const remedy = {
|
|
278
|
+
action:
|
|
279
|
+
'Issue a new token, store it as github-token and run fleet-sync before this one expires; every node adopts it while the old one still works.',
|
|
280
|
+
};
|
|
281
|
+
if (days <= EXPIRY_FAIL_DAYS)
|
|
282
|
+
return {
|
|
283
|
+
...base,
|
|
284
|
+
status: 'fail',
|
|
285
|
+
summary: `GITHUB_TOKEN_EXPIRING: the token expires at ${at}.`,
|
|
286
|
+
remedy,
|
|
287
|
+
};
|
|
288
|
+
if (days <= EXPIRY_WARN_DAYS)
|
|
289
|
+
return {
|
|
290
|
+
...base,
|
|
291
|
+
status: 'warn',
|
|
292
|
+
summary: `GITHUB_TOKEN_EXPIRING_SOON: the token expires at ${at}.`,
|
|
293
|
+
remedy,
|
|
294
|
+
};
|
|
295
|
+
return { ...base, status: 'pass', summary: `The token expires at ${at}.` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function rateLimit(headers: Record<string, string>): Check {
|
|
299
|
+
const limit = Number(headers['x-ratelimit-limit']);
|
|
300
|
+
const remaining = Number(headers['x-ratelimit-remaining']);
|
|
301
|
+
const reset = Number(headers['x-ratelimit-reset']);
|
|
302
|
+
const base = { id: 'github:rate-limit' };
|
|
303
|
+
if (!(limit > 0) || !Number.isFinite(remaining))
|
|
304
|
+
return {
|
|
305
|
+
...base,
|
|
306
|
+
status: 'skip',
|
|
307
|
+
required: false,
|
|
308
|
+
summary: 'GitHub reported no rate limit for this request.',
|
|
309
|
+
};
|
|
310
|
+
const percent = Math.round((remaining / limit) * 100);
|
|
311
|
+
const resetAt = Number.isFinite(reset)
|
|
312
|
+
? new Date(reset * 1000).toISOString()
|
|
313
|
+
: 'an unknown time';
|
|
314
|
+
if (remaining / limit < RATE_WARN_FRACTION)
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
status: 'warn',
|
|
318
|
+
summary: `GITHUB_RATE_LIMIT_LOW: ${remaining} of ${limit} requests (${percent} %) remain until ${resetAt}.`,
|
|
319
|
+
remedy: {
|
|
320
|
+
action:
|
|
321
|
+
'Every node shares the rate limit of one login. Spread the load with per-node credentials (github.credential "per-node") or wait for the reset.',
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
return {
|
|
325
|
+
...base,
|
|
326
|
+
status: 'pass',
|
|
327
|
+
summary: `${remaining} of ${limit} requests (${percent} %) remain until ${resetAt}.`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function organizationAccess(
|
|
332
|
+
run: Runner,
|
|
333
|
+
organizations: string[],
|
|
334
|
+
login: string
|
|
335
|
+
): Promise<Check[]> {
|
|
336
|
+
if (!organizations.length) return [];
|
|
337
|
+
const missing: string[] = [];
|
|
338
|
+
const unauthorized: string[] = [];
|
|
339
|
+
for (const organization of organizations) {
|
|
340
|
+
const result = await run(
|
|
341
|
+
['gh', 'api', '--include', `user/memberships/orgs/${organization}`],
|
|
342
|
+
{ timeoutMs: GITHUB_STEP_TIMEOUT_MS }
|
|
343
|
+
);
|
|
344
|
+
const response = parseResponse(result.stdout);
|
|
345
|
+
if (response.headers['x-github-sso']?.startsWith('required'))
|
|
346
|
+
unauthorized.push(organization);
|
|
347
|
+
else if (!result.ok || response.body?.['state'] !== 'active')
|
|
348
|
+
missing.push(organization);
|
|
349
|
+
}
|
|
350
|
+
if (!missing.length && !unauthorized.length)
|
|
351
|
+
return [
|
|
352
|
+
{
|
|
353
|
+
id: 'github:org-access',
|
|
354
|
+
status: 'pass',
|
|
355
|
+
summary: `${login} is an active member of every declared organisation, and single sign-on has authorised the token.`,
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
const reasons: string[] = [];
|
|
359
|
+
if (unauthorized.length)
|
|
360
|
+
reasons.push(
|
|
361
|
+
`GITHUB_SSO_AUTHORIZATION_REQUIRED: ${unauthorized.join(', ')}`
|
|
362
|
+
);
|
|
363
|
+
if (missing.length)
|
|
364
|
+
reasons.push(`GITHUB_ORG_MEMBERSHIP_MISSING: ${missing.join(', ')}`);
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
id: 'github:org-access',
|
|
368
|
+
status: 'fail',
|
|
369
|
+
summary: reasons.join('; '),
|
|
370
|
+
remedy: {
|
|
371
|
+
action:
|
|
372
|
+
'Authorise the token for single sign-on in each organisation named (GitHub settings, Configure SSO), or add the fleet login to it. This needs a person who administers the organisation.',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** git asks gh for the token at every use, so a replaced token needs no
|
|
379
|
+
* change in git; no second copy of it is written.
|
|
380
|
+
*/
|
|
381
|
+
async function gitCredential(run: Runner): Promise<Check> {
|
|
382
|
+
const helpers = await run(
|
|
383
|
+
[
|
|
384
|
+
'git',
|
|
385
|
+
'config',
|
|
386
|
+
'--global',
|
|
387
|
+
'--get-all',
|
|
388
|
+
'credential.https://github.com.helper',
|
|
389
|
+
],
|
|
390
|
+
{ timeoutMs: 5_000 }
|
|
391
|
+
);
|
|
392
|
+
const ok =
|
|
393
|
+
helpers.ok &&
|
|
394
|
+
helpers.stdout
|
|
395
|
+
.split('\n')
|
|
396
|
+
.some((line) => /(^|[/!\s])gh'? auth git-credential$/.test(line.trim()));
|
|
397
|
+
return {
|
|
398
|
+
id: 'github:git-credential',
|
|
399
|
+
status: ok ? 'pass' : 'fail',
|
|
400
|
+
summary: ok
|
|
401
|
+
? 'git asks gh for the GitHub token at every use.'
|
|
402
|
+
: 'GITHUB_GIT_CREDENTIAL_MISSING: git does not take its GitHub credential from gh.',
|
|
403
|
+
...(ok
|
|
404
|
+
? {}
|
|
405
|
+
: {
|
|
406
|
+
remedy: {
|
|
407
|
+
action:
|
|
408
|
+
'Declare the git block in fleet.json and run fleet-sync: ansible/github/git.yml sets gh as the credential helper. On a manager with a personal login, run gh auth setup-git.',
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Check, Runner } from '../types';
|
|
2
|
+
import type { GithubCode } from './sync';
|
|
3
|
+
|
|
4
|
+
const CODES: GithubCode[] = [
|
|
5
|
+
'GITHUB_OK',
|
|
6
|
+
'GITHUB_TOKEN_MISSING',
|
|
7
|
+
'GITHUB_TOKEN_REJECTED',
|
|
8
|
+
'GITHUB_LOGIN_MISMATCH',
|
|
9
|
+
'GITHUB_UNREACHABLE',
|
|
10
|
+
'GITHUB_SIGN_IN_FAILED',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const REMEDIES: Record<Exclude<GithubCode, 'GITHUB_OK'>, string> = {
|
|
14
|
+
GITHUB_TOKEN_MISSING:
|
|
15
|
+
'Store the fleet GitHub token in the secrets provider under github-token (github-token-<node> with github.credential "per-node"), then run fleet-sync again.',
|
|
16
|
+
GITHUB_TOKEN_REJECTED:
|
|
17
|
+
'GitHub refuses the stored token: it was revoked or has expired. Store a new one under github-token and run fleet-sync again.',
|
|
18
|
+
GITHUB_LOGIN_MISMATCH:
|
|
19
|
+
'The stored token belongs to another account than the fleet configuration declares (worker.githubLogin, or github.manager for the manager). Store the right token or correct the declaration.',
|
|
20
|
+
GITHUB_UNREACHABLE:
|
|
21
|
+
'GitHub did not answer from this node. Check its network and DNS; the node keeps the sign-in it has.',
|
|
22
|
+
GITHUB_SIGN_IN_FAILED:
|
|
23
|
+
'gh refused to store the validated token. Inspect gh on this node (gh auth status); nothing was copied from another machine.',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Runs this node's GitHub sign-in job once and reports what it came to. A
|
|
27
|
+
* failure fails the sync: a node that cannot reach GitHub as the fleet login
|
|
28
|
+
* is not ready for an agent.
|
|
29
|
+
*/
|
|
30
|
+
export async function convergeGithub(
|
|
31
|
+
root: string,
|
|
32
|
+
run: Runner,
|
|
33
|
+
node?: string
|
|
34
|
+
): Promise<Check> {
|
|
35
|
+
const result = await run(
|
|
36
|
+
['bun', 'bootstrap/worker-github.ts', ...(node ? ['--node', node] : [])],
|
|
37
|
+
{ cwd: root, timeoutMs: 240_000 }
|
|
38
|
+
);
|
|
39
|
+
let code: GithubCode | undefined;
|
|
40
|
+
try {
|
|
41
|
+
const line: unknown = JSON.parse(result.stdout.trim().split('\n').pop()!);
|
|
42
|
+
const found = (line as { code?: unknown }).code;
|
|
43
|
+
if (CODES.includes(found as GithubCode)) code = found as GithubCode;
|
|
44
|
+
} catch {}
|
|
45
|
+
if (code === 'GITHUB_OK')
|
|
46
|
+
return {
|
|
47
|
+
id: 'github:sync',
|
|
48
|
+
status: 'pass',
|
|
49
|
+
summary:
|
|
50
|
+
'This node is signed in to GitHub as its declared login with the stored credential.',
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
id: 'github:sync',
|
|
54
|
+
status: 'fail',
|
|
55
|
+
summary: code ?? 'GITHUB_SYNC_UNREPORTED',
|
|
56
|
+
remedy: {
|
|
57
|
+
action: code
|
|
58
|
+
? REMEDIES[code]
|
|
59
|
+
: 'The GitHub sign-in job reported no result. Run bun bootstrap/worker-github.ts on this node to see its result line.',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|