@crvouga/dev-fleet 0.5.0 → 0.7.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 +4 -10
- package/ansible/macos/manager.yml +39 -2
- package/ansible/macos/policy.json +6 -1
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +8 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/ansible/ubuntu/tasks/github.yml +50 -0
- 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 +7 -4
- package/docs/github.md +107 -0
- package/docs/manager-recovery.md +78 -0
- package/docs/operations.md +9 -1
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +4 -5
- package/docs/ubuntu-sync.md +5 -5
- package/package.json +2 -2
- package/schema/check-ids.json +51 -3
- package/schema/fleet.schema.json +79 -1
- package/src/auth-checks.ts +14 -6
- package/src/check-owners.ts +163 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-manager.ts +62 -15
- package/src/cli/fleet-sync-state.ts +18 -8
- package/src/cli/fleet-sync.ts +32 -3
- package/src/cli/github-verify.ts +57 -0
- package/src/cli/register-fleet.ts +33 -0
- package/src/cli/registry.ts +21 -1
- package/src/definition.ts +11 -1
- 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/node-converge-linux.ts +3 -2
- package/src/node-converge-shared.ts +6 -2
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +66 -2
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +58 -13
- package/src/service-auth.ts +2 -2
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-access-checks.ts +66 -16
- package/src/worker-readiness.ts +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { FleetDefinition } from '../definition';
|
|
2
|
+
import type { SecretsProvider } from '../secrets';
|
|
3
|
+
import { githubPolicy } from './policy';
|
|
4
|
+
|
|
5
|
+
/** Where a node's GitHub token comes from. Every source is read through the
|
|
6
|
+
* fleet's secrets provider, by the declared name `github-token`:
|
|
7
|
+
* - `shared`: one token for the whole fleet;
|
|
8
|
+
* - `per-node`: the node's own token (`github-token-<node>`), so revoking one
|
|
9
|
+
* node's token leaves every other node signed in.
|
|
10
|
+
* gh on the node holds the token it was given; git and every other tool ask
|
|
11
|
+
* gh for it each time (`gh auth git-credential`), so a replaced token reaches
|
|
12
|
+
* them without restarting anything.
|
|
13
|
+
*/
|
|
14
|
+
export function githubCredential(
|
|
15
|
+
definition: FleetDefinition,
|
|
16
|
+
node: string,
|
|
17
|
+
secret: SecretsProvider
|
|
18
|
+
): () => Promise<string | null> {
|
|
19
|
+
const perNode = githubPolicy(definition).credential === 'per-node';
|
|
20
|
+
return () => secret('github-token', perNode ? node : undefined);
|
|
21
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { record } from '../config';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import type { FleetDefinition } from '../definition';
|
|
4
|
+
|
|
5
|
+
/** How the fleet signs in to GitHub, from the optional `github` block of its
|
|
6
|
+
* fleet configuration. The fleet login itself is `worker.githubLogin`.
|
|
7
|
+
*/
|
|
8
|
+
export interface GithubPolicy {
|
|
9
|
+
/** `fleet`: the manager signs in as the fleet login, like every worker.
|
|
10
|
+
* `{ login }`: the manager keeps a person's own login, which is checked and
|
|
11
|
+
* never replaced.
|
|
12
|
+
*/
|
|
13
|
+
manager: 'fleet' | { login: string };
|
|
14
|
+
/** `shared`: one token for every node (`github-token`). `per-node`: each
|
|
15
|
+
* node has its own (`github-token-<node>`), so one can be revoked alone.
|
|
16
|
+
*/
|
|
17
|
+
credential: 'shared' | 'per-node';
|
|
18
|
+
/** Scopes the token must carry, such as `repo` and `workflow`. */
|
|
19
|
+
scopes: string[];
|
|
20
|
+
/** `owner/name` of repositories every node must be able to push to. */
|
|
21
|
+
repositories: string[];
|
|
22
|
+
/** Organisations whose single sign-on must have authorised the token. */
|
|
23
|
+
organizations: string[];
|
|
24
|
+
/** `owner/name` of a repository `fleet-github-verify` may branch, push to
|
|
25
|
+
* and open pull requests on, then clean up.
|
|
26
|
+
*/
|
|
27
|
+
sandbox?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_GITHUB: GithubPolicy = {
|
|
31
|
+
manager: 'fleet',
|
|
32
|
+
credential: 'shared',
|
|
33
|
+
scopes: [],
|
|
34
|
+
repositories: [],
|
|
35
|
+
organizations: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const LOGIN = /^[a-zA-Z0-9-]{1,39}$/;
|
|
39
|
+
const REPOSITORY = /^[a-zA-Z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
40
|
+
const SCOPE = /^[a-z:_]{1,40}$/;
|
|
41
|
+
const FIELDS = [
|
|
42
|
+
'manager',
|
|
43
|
+
'credential',
|
|
44
|
+
'scopes',
|
|
45
|
+
'repositories',
|
|
46
|
+
'organizations',
|
|
47
|
+
'sandbox',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function fail(): never {
|
|
51
|
+
throw new FleetUsageError(
|
|
52
|
+
'Invalid github block in fleet.json. "manager" is "fleet" or {"login": ...}, "credential" is "shared" or "per-node", "scopes", "repositories" (owner/name) and "organizations" are lists, "sandbox" is owner/name.'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function list(value: unknown, pattern: RegExp): string[] {
|
|
57
|
+
if (value === undefined) return [];
|
|
58
|
+
if (
|
|
59
|
+
!Array.isArray(value) ||
|
|
60
|
+
value.length > 64 ||
|
|
61
|
+
new Set(value).size !== value.length ||
|
|
62
|
+
!value.every((item) => typeof item === 'string' && pattern.test(item))
|
|
63
|
+
)
|
|
64
|
+
return fail();
|
|
65
|
+
return value as string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function managerLogin(value: unknown): GithubPolicy['manager'] {
|
|
69
|
+
if (value === undefined || value === 'fleet') return 'fleet';
|
|
70
|
+
if (
|
|
71
|
+
record(value) &&
|
|
72
|
+
Object.keys(value).length === 1 &&
|
|
73
|
+
typeof value['login'] === 'string' &&
|
|
74
|
+
LOGIN.test(value['login'])
|
|
75
|
+
)
|
|
76
|
+
return { login: value['login'] };
|
|
77
|
+
return fail();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function repository(value: unknown): string | undefined {
|
|
81
|
+
if (value === undefined) return undefined;
|
|
82
|
+
return typeof value === 'string' && REPOSITORY.test(value) ? value : fail();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseGithubPolicy(value: unknown): GithubPolicy {
|
|
86
|
+
if (
|
|
87
|
+
!record(value) ||
|
|
88
|
+
!Object.keys(value).every((key) => FIELDS.includes(key))
|
|
89
|
+
)
|
|
90
|
+
return fail();
|
|
91
|
+
const credential = value['credential'] ?? 'shared';
|
|
92
|
+
if (credential !== 'shared' && credential !== 'per-node') return fail();
|
|
93
|
+
const sandbox = repository(value['sandbox']);
|
|
94
|
+
return {
|
|
95
|
+
manager: managerLogin(value['manager']),
|
|
96
|
+
credential,
|
|
97
|
+
scopes: list(value['scopes'], SCOPE),
|
|
98
|
+
repositories: list(value['repositories'], REPOSITORY),
|
|
99
|
+
organizations: list(value['organizations'], LOGIN),
|
|
100
|
+
...(sandbox === undefined ? {} : { sandbox }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type GithubRole = 'manager' | 'worker';
|
|
105
|
+
|
|
106
|
+
/** What GitHub sign-in on one node is held to. */
|
|
107
|
+
export interface GithubNode {
|
|
108
|
+
role: GithubRole;
|
|
109
|
+
/** The node's id: it names a per-node credential. */
|
|
110
|
+
node: string;
|
|
111
|
+
/** The login the node must be signed in as; null when the fleet declares
|
|
112
|
+
* none (no worker policy).
|
|
113
|
+
*/
|
|
114
|
+
login: string | null;
|
|
115
|
+
/** Whether dev-fleet signs this node in from the stored credential, or only
|
|
116
|
+
* checks a person's own login.
|
|
117
|
+
*/
|
|
118
|
+
managed: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function githubPolicy(definition: FleetDefinition): GithubPolicy {
|
|
122
|
+
return definition.github ?? DEFAULT_GITHUB;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The manager when `node` is not a declared worker. */
|
|
126
|
+
export function githubNode(
|
|
127
|
+
definition: FleetDefinition,
|
|
128
|
+
node?: string
|
|
129
|
+
): GithubNode {
|
|
130
|
+
const policy = githubPolicy(definition);
|
|
131
|
+
const fleetLogin = definition.worker?.githubLogin ?? null;
|
|
132
|
+
if (node !== undefined && definition.workers.some((w) => w.id === node))
|
|
133
|
+
return { role: 'worker', node, login: fleetLogin, managed: true };
|
|
134
|
+
const personal = policy.manager !== 'fleet';
|
|
135
|
+
return {
|
|
136
|
+
role: 'manager',
|
|
137
|
+
node: definition.manager.id,
|
|
138
|
+
login: policy.manager === 'fleet' ? fleetLogin : policy.manager.login,
|
|
139
|
+
managed: !personal,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { CommandResult, Runner } from '../types';
|
|
2
|
+
|
|
3
|
+
/** What GitHub sign-in on a node came to. Each failure names the system to
|
|
4
|
+
* fix: the store (`MISSING`), GitHub's verdict on the stored token
|
|
5
|
+
* (`REJECTED`), the fleet configuration or the store (`MISMATCH`), the network
|
|
6
|
+
* (`UNREACHABLE`).
|
|
7
|
+
*/
|
|
8
|
+
export type GithubCode =
|
|
9
|
+
| 'GITHUB_OK'
|
|
10
|
+
| 'GITHUB_TOKEN_MISSING'
|
|
11
|
+
| 'GITHUB_TOKEN_REJECTED'
|
|
12
|
+
| 'GITHUB_LOGIN_MISMATCH'
|
|
13
|
+
| 'GITHUB_UNREACHABLE'
|
|
14
|
+
| 'GITHUB_SIGN_IN_FAILED';
|
|
15
|
+
|
|
16
|
+
export interface GithubSyncResult {
|
|
17
|
+
code: GithubCode;
|
|
18
|
+
/** The stored token replaced the one the node held. */
|
|
19
|
+
adopted: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Each GitHub request and the secret read is bounded by this; the unit that
|
|
23
|
+
* runs a sync allows every step its full time (ansible/ubuntu/tasks/github.yml).
|
|
24
|
+
*/
|
|
25
|
+
export const GITHUB_STEP_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
export interface GithubSyncInput {
|
|
28
|
+
run: Runner;
|
|
29
|
+
/** The stored credential of this node, through the fleet's secrets provider. */
|
|
30
|
+
token: () => Promise<string | null>;
|
|
31
|
+
/** The login the node must be signed in as. */
|
|
32
|
+
login: string;
|
|
33
|
+
/** False: measure only, change nothing (a check, or a person's own login). */
|
|
34
|
+
apply: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TOKEN = /^[\x21-\x7e]{16,4096}$/;
|
|
38
|
+
/** gh prints GitHub's status line on a refused request. */
|
|
39
|
+
const REFUSED = /HTTP 401|Bad credentials/i;
|
|
40
|
+
|
|
41
|
+
/** gh with no token in its environment: it uses only the login it stores. */
|
|
42
|
+
const stored = { GH_TOKEN: undefined, GITHUB_TOKEN: undefined };
|
|
43
|
+
|
|
44
|
+
async function whoami(run: Runner, token?: string): Promise<CommandResult> {
|
|
45
|
+
return run(['gh', 'api', 'user', '--jq', '.login'], {
|
|
46
|
+
timeoutMs: GITHUB_STEP_TIMEOUT_MS,
|
|
47
|
+
env: token ? { GH_TOKEN: token, GITHUB_TOKEN: undefined } : stored,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The token gh signs requests with on this node, held in memory only. */
|
|
52
|
+
export async function activeToken(
|
|
53
|
+
run: Runner,
|
|
54
|
+
timeoutMs = GITHUB_STEP_TIMEOUT_MS
|
|
55
|
+
): Promise<string | null> {
|
|
56
|
+
const result = await run(
|
|
57
|
+
['gh', 'auth', 'token', '--hostname', 'github.com'],
|
|
58
|
+
{ timeoutMs, env: stored }
|
|
59
|
+
);
|
|
60
|
+
const token = result.stdout.trim();
|
|
61
|
+
return result.ok && TOKEN.test(token) ? token : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function refused(result: CommandResult): GithubCode {
|
|
65
|
+
return REFUSED.test(result.stderr) || REFUSED.test(result.stdout)
|
|
66
|
+
? 'GITHUB_TOKEN_REJECTED'
|
|
67
|
+
: 'GITHUB_UNREACHABLE';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Brings this node's GitHub sign-in to the stored credential, or with
|
|
71
|
+
* `apply: false` says what doing so would find. A token that differs from the
|
|
72
|
+
* one gh holds is adopted while the old one still works, so a rotation never
|
|
73
|
+
* waits for a revocation to break every node at once. A token is never in
|
|
74
|
+
* argv, a log or a result: gh receives it on standard input.
|
|
75
|
+
*/
|
|
76
|
+
export async function syncGithub(
|
|
77
|
+
input: GithubSyncInput
|
|
78
|
+
): Promise<GithubSyncResult> {
|
|
79
|
+
const { run, login, apply } = input;
|
|
80
|
+
const current = await whoami(run);
|
|
81
|
+
const signedIn = current.ok && current.stdout.trim() === login;
|
|
82
|
+
const token = ((await input.token()) ?? '').trim();
|
|
83
|
+
if (!TOKEN.test(token))
|
|
84
|
+
return {
|
|
85
|
+
code: signedIn ? 'GITHUB_OK' : 'GITHUB_TOKEN_MISSING',
|
|
86
|
+
adopted: false,
|
|
87
|
+
};
|
|
88
|
+
if (signedIn && (await activeToken(run)) === token)
|
|
89
|
+
return { code: 'GITHUB_OK', adopted: false };
|
|
90
|
+
const owner = await whoami(run, token);
|
|
91
|
+
if (!owner.ok) {
|
|
92
|
+
// A stored token GitHub refuses is not adopted; the node keeps the one
|
|
93
|
+
// that still works, and the failure names the store.
|
|
94
|
+
return { code: refused(owner), adopted: false };
|
|
95
|
+
}
|
|
96
|
+
if (owner.stdout.trim() !== login)
|
|
97
|
+
return { code: 'GITHUB_LOGIN_MISMATCH', adopted: false };
|
|
98
|
+
// A measurement says what a sync would do: adopt the stored token.
|
|
99
|
+
if (!apply) return { code: 'GITHUB_OK', adopted: false };
|
|
100
|
+
const signIn = await run(
|
|
101
|
+
['gh', 'auth', 'login', '--hostname', 'github.com', '--with-token'],
|
|
102
|
+
{ input: token + '\n', env: stored, timeoutMs: GITHUB_STEP_TIMEOUT_MS }
|
|
103
|
+
);
|
|
104
|
+
if (!signIn.ok) return { code: 'GITHUB_SIGN_IN_FAILED', adopted: false };
|
|
105
|
+
return { code: 'GITHUB_OK', adopted: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Why a node is not signed in, for a check: measures, never signs in. When the
|
|
109
|
+
* node is signed in as the declared login nothing else is read.
|
|
110
|
+
*/
|
|
111
|
+
export async function githubSignInState(
|
|
112
|
+
input: Omit<GithubSyncInput, 'apply'>
|
|
113
|
+
): Promise<GithubCode> {
|
|
114
|
+
const current = await whoami(input.run);
|
|
115
|
+
if (current.ok && current.stdout.trim() === input.login) return 'GITHUB_OK';
|
|
116
|
+
const token = ((await input.token()) ?? '').trim();
|
|
117
|
+
if (!TOKEN.test(token)) return 'GITHUB_TOKEN_MISSING';
|
|
118
|
+
const owner = await whoami(input.run, token);
|
|
119
|
+
if (!owner.ok) return refused(owner);
|
|
120
|
+
if (owner.stdout.trim() !== input.login) return 'GITHUB_LOGIN_MISMATCH';
|
|
121
|
+
// The stored token is good; the node has yet to adopt it.
|
|
122
|
+
return 'GITHUB_SIGN_IN_FAILED';
|
|
123
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Check, Runner } from '../types';
|
|
5
|
+
|
|
6
|
+
/** The steps of the work an agent does on GitHub, in order. A failure names
|
|
7
|
+
* the first step that failed.
|
|
8
|
+
*/
|
|
9
|
+
export const WORKFLOW_STEPS = [
|
|
10
|
+
'clone',
|
|
11
|
+
'branch',
|
|
12
|
+
'commit',
|
|
13
|
+
'push',
|
|
14
|
+
'pull-request',
|
|
15
|
+
'read-checks',
|
|
16
|
+
'comment',
|
|
17
|
+
] as const;
|
|
18
|
+
export type WorkflowStep = (typeof WORKFLOW_STEPS)[number];
|
|
19
|
+
|
|
20
|
+
export interface WorkflowInput {
|
|
21
|
+
run: Runner;
|
|
22
|
+
/** `owner/name` of the declared sandbox repository. */
|
|
23
|
+
sandbox: string;
|
|
24
|
+
/** The node that runs it: part of the branch name. */
|
|
25
|
+
node: string;
|
|
26
|
+
/** A unique suffix for the branch; time and randomness by default. */
|
|
27
|
+
unique?: string;
|
|
28
|
+
/** A private, empty directory for the clone; made and removed by default. */
|
|
29
|
+
directory?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WorkflowResult {
|
|
33
|
+
check: Check;
|
|
34
|
+
/** The step that failed, if one did. */
|
|
35
|
+
failed?: WorkflowStep;
|
|
36
|
+
/** The branch the run used, deleted again unless `leftBehind`. */
|
|
37
|
+
branch: string;
|
|
38
|
+
/** What clean-up could not remove: a branch or a pull request. */
|
|
39
|
+
leftBehind: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STEP_TIMEOUT_MS = 60_000;
|
|
43
|
+
|
|
44
|
+
/** Runs the chain an agent depends on against the sandbox repository: clone,
|
|
45
|
+
* branch, commit with the configured identity, push, open a draft pull
|
|
46
|
+
* request, read its checks, comment; then always closes the pull request and
|
|
47
|
+
* deletes the branch, whatever failed. Every step is bounded.
|
|
48
|
+
*/
|
|
49
|
+
export async function verifyGithubWorkflow(
|
|
50
|
+
input: WorkflowInput
|
|
51
|
+
): Promise<WorkflowResult> {
|
|
52
|
+
const { run, sandbox, node } = input;
|
|
53
|
+
const unique =
|
|
54
|
+
input.unique ??
|
|
55
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
56
|
+
const branch = `devfleet-verify/${node}-${unique}`;
|
|
57
|
+
const directory =
|
|
58
|
+
input.directory ?? (await mkdtemp(join(tmpdir(), 'devfleet-verify-')));
|
|
59
|
+
const clone = join(directory, 'sandbox');
|
|
60
|
+
const git = (args: string[]) =>
|
|
61
|
+
run(['git', '-C', clone, ...args], { timeoutMs: STEP_TIMEOUT_MS });
|
|
62
|
+
const gh = (args: string[]) =>
|
|
63
|
+
run(['gh', ...args], { timeoutMs: STEP_TIMEOUT_MS });
|
|
64
|
+
let failed: WorkflowStep | undefined;
|
|
65
|
+
let pushed = false;
|
|
66
|
+
let pullRequest: string | null = null;
|
|
67
|
+
const leftBehind: string[] = [];
|
|
68
|
+
const step = async (
|
|
69
|
+
name: WorkflowStep,
|
|
70
|
+
action: () => Promise<boolean>
|
|
71
|
+
): Promise<boolean> => {
|
|
72
|
+
if (failed) return false;
|
|
73
|
+
if (await action()) return true;
|
|
74
|
+
failed = name;
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
await step('clone', async () => {
|
|
79
|
+
const result = await run(
|
|
80
|
+
[
|
|
81
|
+
'git',
|
|
82
|
+
'clone',
|
|
83
|
+
'--depth',
|
|
84
|
+
'1',
|
|
85
|
+
`https://github.com/${sandbox}.git`,
|
|
86
|
+
clone,
|
|
87
|
+
],
|
|
88
|
+
{ timeoutMs: STEP_TIMEOUT_MS }
|
|
89
|
+
);
|
|
90
|
+
return result.ok;
|
|
91
|
+
});
|
|
92
|
+
await step('branch', async () => (await git(['switch', '-c', branch])).ok);
|
|
93
|
+
await step(
|
|
94
|
+
'commit',
|
|
95
|
+
async () =>
|
|
96
|
+
(
|
|
97
|
+
await git([
|
|
98
|
+
'commit',
|
|
99
|
+
'--allow-empty',
|
|
100
|
+
'--message',
|
|
101
|
+
`devfleet: verify the GitHub workflow from ${node}`,
|
|
102
|
+
])
|
|
103
|
+
).ok
|
|
104
|
+
);
|
|
105
|
+
await step('push', async () => {
|
|
106
|
+
pushed = (await git(['push', '--set-upstream', 'origin', branch])).ok;
|
|
107
|
+
return pushed;
|
|
108
|
+
});
|
|
109
|
+
await step('pull-request', async () => {
|
|
110
|
+
const created = await gh([
|
|
111
|
+
'pr',
|
|
112
|
+
'create',
|
|
113
|
+
'--repo',
|
|
114
|
+
sandbox,
|
|
115
|
+
'--head',
|
|
116
|
+
branch,
|
|
117
|
+
'--draft',
|
|
118
|
+
'--title',
|
|
119
|
+
`devfleet verify ${node}`,
|
|
120
|
+
'--body',
|
|
121
|
+
'Opened by devfleet fleet-github-verify; closed again by the same run.',
|
|
122
|
+
]);
|
|
123
|
+
const url = created.stdout.trim().split('\n').pop() ?? '';
|
|
124
|
+
if (created.ok && /^https:\/\/\S+\/pull\/\d+$/.test(url))
|
|
125
|
+
pullRequest = url;
|
|
126
|
+
return pullRequest !== null;
|
|
127
|
+
});
|
|
128
|
+
await step(
|
|
129
|
+
'read-checks',
|
|
130
|
+
async () =>
|
|
131
|
+
(
|
|
132
|
+
await gh([
|
|
133
|
+
'pr',
|
|
134
|
+
'view',
|
|
135
|
+
pullRequest!,
|
|
136
|
+
'--repo',
|
|
137
|
+
sandbox,
|
|
138
|
+
'--json',
|
|
139
|
+
'statusCheckRollup',
|
|
140
|
+
])
|
|
141
|
+
).ok
|
|
142
|
+
);
|
|
143
|
+
await step(
|
|
144
|
+
'comment',
|
|
145
|
+
async () =>
|
|
146
|
+
(
|
|
147
|
+
await gh([
|
|
148
|
+
'pr',
|
|
149
|
+
'comment',
|
|
150
|
+
pullRequest!,
|
|
151
|
+
'--repo',
|
|
152
|
+
sandbox,
|
|
153
|
+
'--body',
|
|
154
|
+
'devfleet fleet-github-verify: comment step.',
|
|
155
|
+
])
|
|
156
|
+
).ok
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
// Clean-up runs whatever failed, and is itself checked.
|
|
160
|
+
if (pullRequest !== null) {
|
|
161
|
+
const closed = await gh([
|
|
162
|
+
'pr',
|
|
163
|
+
'close',
|
|
164
|
+
pullRequest,
|
|
165
|
+
'--repo',
|
|
166
|
+
sandbox,
|
|
167
|
+
'--delete-branch',
|
|
168
|
+
]);
|
|
169
|
+
if (!closed.ok) leftBehind.push(pullRequest, branch);
|
|
170
|
+
} else if (pushed) {
|
|
171
|
+
const deleted = await git(['push', 'origin', '--delete', branch]);
|
|
172
|
+
if (!deleted.ok) leftBehind.push(branch);
|
|
173
|
+
}
|
|
174
|
+
if (!input.directory) await rm(directory, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
return result(branch, failed, leftBehind);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function result(
|
|
180
|
+
branch: string,
|
|
181
|
+
failed: WorkflowStep | undefined,
|
|
182
|
+
leftBehind: string[]
|
|
183
|
+
): WorkflowResult {
|
|
184
|
+
const check: Check =
|
|
185
|
+
failed === undefined && leftBehind.length === 0
|
|
186
|
+
? {
|
|
187
|
+
id: 'github:workflow',
|
|
188
|
+
status: 'pass',
|
|
189
|
+
summary:
|
|
190
|
+
'Cloned the sandbox, pushed a branch, opened a draft pull request, read its checks and commented, then cleaned up.',
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
193
|
+
id: 'github:workflow',
|
|
194
|
+
status: 'fail',
|
|
195
|
+
summary: failed
|
|
196
|
+
? `GITHUB_WORKFLOW_FAILED: ${failed}`
|
|
197
|
+
: `GITHUB_WORKFLOW_CLEANUP_FAILED: ${leftBehind.join(', ')}`,
|
|
198
|
+
remedy: {
|
|
199
|
+
action: failed
|
|
200
|
+
? remedyFor(failed)
|
|
201
|
+
: 'Close the pull request and delete the branch named on the sandbox repository.',
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
return { check, ...(failed ? { failed } : {}), branch, leftBehind };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function remedyFor(step: WorkflowStep): string {
|
|
208
|
+
return {
|
|
209
|
+
clone:
|
|
210
|
+
'This node cannot clone the sandbox over HTTPS: check github:git-credential and that the fleet login can read github.sandbox.',
|
|
211
|
+
branch:
|
|
212
|
+
'git could not create a branch in a fresh clone; check git on this node.',
|
|
213
|
+
commit:
|
|
214
|
+
'git could not commit: set user.name and user.email for the fleet login on this node.',
|
|
215
|
+
push: 'The token cannot push to the sandbox: check github:permissions (repo or contents: write).',
|
|
216
|
+
'pull-request':
|
|
217
|
+
'The token cannot open a pull request: it needs the pull requests permission on the sandbox.',
|
|
218
|
+
'read-checks':
|
|
219
|
+
'The token cannot read the checks of a pull request: it needs checks and statuses read access.',
|
|
220
|
+
comment:
|
|
221
|
+
'The token cannot comment on a pull request: it needs the pull requests (write) permission.',
|
|
222
|
+
}[step];
|
|
223
|
+
}
|
|
@@ -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
|
|