@appforge-ci/core 0.2.3 → 0.3.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/dist/agent-readiness.d.ts +240 -1
- package/dist/agent-readiness.d.ts.map +1 -1
- package/dist/agent-readiness.js +304 -1
- package/dist/agent-readiness.js.map +1 -1
- package/dist/agent-readiness.test.js +277 -19
- package/dist/agent-readiness.test.js.map +1 -1
- package/dist/api-client.d.ts +80 -3
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +79 -0
- package/dist/api-client.js.map +1 -1
- package/dist/api-client.test.js +22 -0
- package/dist/api-client.test.js.map +1 -1
- package/dist/appforge-verify.test.d.ts +2 -0
- package/dist/appforge-verify.test.d.ts.map +1 -0
- package/dist/appforge-verify.test.js +958 -0
- package/dist/appforge-verify.test.js.map +1 -0
- package/dist/attestation-keys.d.ts +20 -0
- package/dist/attestation-keys.d.ts.map +1 -0
- package/dist/attestation-keys.js +27 -0
- package/dist/attestation-keys.js.map +1 -0
- package/dist/attestation-keys.test.d.ts +2 -0
- package/dist/attestation-keys.test.d.ts.map +1 -0
- package/dist/attestation-keys.test.js +45 -0
- package/dist/attestation-keys.test.js.map +1 -0
- package/dist/attestation-signing.d.ts +226 -0
- package/dist/attestation-signing.d.ts.map +1 -0
- package/dist/attestation-signing.js +276 -0
- package/dist/attestation-signing.js.map +1 -0
- package/dist/attestation-signing.test.d.ts +2 -0
- package/dist/attestation-signing.test.d.ts.map +1 -0
- package/dist/attestation-signing.test.js +250 -0
- package/dist/attestation-signing.test.js.map +1 -0
- package/dist/attestation-workerd.test.d.ts +2 -0
- package/dist/attestation-workerd.test.d.ts.map +1 -0
- package/dist/attestation-workerd.test.js +183 -0
- package/dist/attestation-workerd.test.js.map +1 -0
- package/dist/attestation.d.ts +412 -0
- package/dist/attestation.d.ts.map +1 -0
- package/dist/attestation.js +353 -0
- package/dist/attestation.js.map +1 -0
- package/dist/attestation.test.d.ts +2 -0
- package/dist/attestation.test.d.ts.map +1 -0
- package/dist/attestation.test.js +218 -0
- package/dist/attestation.test.js.map +1 -0
- package/dist/build-vpn.d.ts +20 -0
- package/dist/build-vpn.d.ts.map +1 -0
- package/dist/build-vpn.js +50 -0
- package/dist/build-vpn.js.map +1 -0
- package/dist/fleet-vpn.d.ts +64 -0
- package/dist/fleet-vpn.d.ts.map +1 -0
- package/dist/fleet-vpn.js +88 -0
- package/dist/fleet-vpn.js.map +1 -0
- package/dist/fleet-vpn.test.d.ts +2 -0
- package/dist/fleet-vpn.test.d.ts.map +1 -0
- package/dist/fleet-vpn.test.js +69 -0
- package/dist/fleet-vpn.test.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas-repo-url.test.d.ts +2 -0
- package/dist/schemas-repo-url.test.d.ts.map +1 -0
- package/dist/schemas-repo-url.test.js +67 -0
- package/dist/schemas-repo-url.test.js.map +1 -0
- package/dist/schemas.d.ts +360 -7
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +148 -12
- package/dist/schemas.js.map +1 -1
- package/dist/ssh-deploy-key.d.ts +59 -0
- package/dist/ssh-deploy-key.d.ts.map +1 -0
- package/dist/ssh-deploy-key.js +124 -0
- package/dist/ssh-deploy-key.js.map +1 -0
- package/dist/ssh-deploy-key.test.d.ts +2 -0
- package/dist/ssh-deploy-key.test.d.ts.map +1 -0
- package/dist/ssh-deploy-key.test.js +117 -0
- package/dist/ssh-deploy-key.test.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +1 -1
- package/dist/vpn-attestation-workerd.test.d.ts +2 -0
- package/dist/vpn-attestation-workerd.test.d.ts.map +1 -0
- package/dist/vpn-attestation-workerd.test.js +184 -0
- package/dist/vpn-attestation-workerd.test.js.map +1 -0
- package/dist/vpn-attestation.d.ts +1015 -0
- package/dist/vpn-attestation.d.ts.map +1 -0
- package/dist/vpn-attestation.js +963 -0
- package/dist/vpn-attestation.js.map +1 -0
- package/dist/vpn-attestation.test.d.ts +2 -0
- package/dist/vpn-attestation.test.d.ts.map +1 -0
- package/dist/vpn-attestation.test.js +613 -0
- package/dist/vpn-attestation.test.js.map +1 -0
- package/dist/vpn-egress-presets.d.ts +142 -0
- package/dist/vpn-egress-presets.d.ts.map +1 -0
- package/dist/vpn-egress-presets.js +318 -0
- package/dist/vpn-egress-presets.js.map +1 -0
- package/dist/vpn-egress-presets.test.d.ts +2 -0
- package/dist/vpn-egress-presets.test.d.ts.map +1 -0
- package/dist/vpn-egress-presets.test.js +210 -0
- package/dist/vpn-egress-presets.test.js.map +1 -0
- package/dist/vpn-egress.d.ts +57 -0
- package/dist/vpn-egress.d.ts.map +1 -0
- package/dist/vpn-egress.js +126 -0
- package/dist/vpn-egress.js.map +1 -0
- package/dist/vpn-egress.test.d.ts +2 -0
- package/dist/vpn-egress.test.d.ts.map +1 -0
- package/dist/vpn-egress.test.js +133 -0
- package/dist/vpn-egress.test.js.map +1 -0
- package/dist/vpn-sandbox.d.ts +67 -0
- package/dist/vpn-sandbox.d.ts.map +1 -0
- package/dist/vpn-sandbox.js +22 -0
- package/dist/vpn-sandbox.js.map +1 -0
- package/dist/vpn-sandbox.test.d.ts +2 -0
- package/dist/vpn-sandbox.test.d.ts.map +1 -0
- package/dist/vpn-sandbox.test.js +34 -0
- package/dist/vpn-sandbox.test.js.map +1 -0
- package/dist/vpn-ssh.d.ts +138 -0
- package/dist/vpn-ssh.d.ts.map +1 -0
- package/dist/vpn-ssh.js +320 -0
- package/dist/vpn-ssh.js.map +1 -0
- package/dist/vpn-ssh.test.d.ts +2 -0
- package/dist/vpn-ssh.test.d.ts.map +1 -0
- package/dist/vpn-ssh.test.js +274 -0
- package/dist/vpn-ssh.test.js.map +1 -0
- package/package.json +1 -1
|
@@ -98,6 +98,183 @@ export declare function agentFitsPlatform(readiness: AgentReadiness, platform: P
|
|
|
98
98
|
* getting bound somewhere it can't actually run.
|
|
99
99
|
*/
|
|
100
100
|
export declare function agentCanBuildPlatform(agent: Parameters<typeof computeAgentReadiness>[0], platform: Platform, now?: Date): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Build-isolation level an agent reports (`Agent.enforcementLevel`,
|
|
103
|
+
* migrations/0043). Ordered: a higher level satisfies a lower requirement.
|
|
104
|
+
* - `none` no kernel-enforced isolation of build processes (today).
|
|
105
|
+
* - `sandbox` builds run under a kernel sandbox profile (Seatbelt).
|
|
106
|
+
* - `sandbox_uid` ... and as a dedicated unprivileged user.
|
|
107
|
+
* Anything unrecognised ranks as `none`: an unknown level never satisfies a
|
|
108
|
+
* requirement (fail closed).
|
|
109
|
+
*/
|
|
110
|
+
export declare const ENFORCEMENT_LEVELS: readonly ["none", "sandbox", "sandbox_uid"];
|
|
111
|
+
export type EnforcementLevel = (typeof ENFORCEMENT_LEVELS)[number];
|
|
112
|
+
export declare function parseEnforcementLevel(raw: unknown): EnforcementLevel;
|
|
113
|
+
/** 0 (`none`) .. 2 (`sandbox_uid`); unknown input is 0. */
|
|
114
|
+
export declare function enforcementLevelRank(level: unknown): number;
|
|
115
|
+
/** What a feature id may look like (`Agent.vpnFeatures` entries, tunnel
|
|
116
|
+
* `requires` entries): lowercase, short, no whitespace or separators that
|
|
117
|
+
* could be abused in a log line or SQL JSON. Ids carry their own version
|
|
118
|
+
* suffix (`ssh-v1`) so a breaking change is a NEW id, never a redefinition. */
|
|
119
|
+
export declare const VPN_FEATURE_ID_PATTERN: RegExp;
|
|
120
|
+
/** An agent reports at most this many features; the rest are dropped. */
|
|
121
|
+
export declare const MAX_VPN_FEATURES = 32;
|
|
122
|
+
/**
|
|
123
|
+
* Feature ids the regulated-VPN program will introduce. Informational: the
|
|
124
|
+
* scheduler's superset check works on whatever ids a tunnel requires and an
|
|
125
|
+
* agent reports, so a new id needs no change here. Only `egress-policy-v1` is
|
|
126
|
+
* required (by a strict-egress tunnel) and reported (by an agent with a new
|
|
127
|
+
* enough verified helper) so far; the others land in later PRs.
|
|
128
|
+
*/
|
|
129
|
+
export declare const KNOWN_VPN_FEATURES: readonly ["egress-policy-v1", "ssh-v1", "sandbox-v1", "evidence-v1"];
|
|
130
|
+
/** Prefix of a requirement token that asks for a minimum enforcement level
|
|
131
|
+
* (`enforcement:sandbox`) rather than a feature id. */
|
|
132
|
+
export declare const ENFORCEMENT_REQUIREMENT_PREFIX = "enforcement:";
|
|
133
|
+
/**
|
|
134
|
+
* The canonical form of a reported feature list: only well-formed ids,
|
|
135
|
+
* de-duplicated, sorted, capped at `MAX_VPN_FEATURES`. Anything else is
|
|
136
|
+
* dropped, never repaired. This is the ONLY form stored in
|
|
137
|
+
* `Agent.vpnFeatures`, so the scheduler's SQL superset check can compare
|
|
138
|
+
* strings exactly.
|
|
139
|
+
*/
|
|
140
|
+
export declare function canonicalVpnFeatures(input: readonly unknown[] | null | undefined): string[];
|
|
141
|
+
/** Parses the `Agent.vpnFeatures` JSON column. Malformed, non-array or null
|
|
142
|
+
* input is `[]` (no features), never an exception. */
|
|
143
|
+
export declare function parseVpnFeatures(raw: string | null | undefined): string[];
|
|
144
|
+
/** What a tunnel needs from the Mac that serves its builds. */
|
|
145
|
+
export interface VpnRequirements {
|
|
146
|
+
/** Feature ids the agent must report (a superset check). */
|
|
147
|
+
features: readonly string[];
|
|
148
|
+
/** Minimum `enforcementLevel`. */
|
|
149
|
+
minEnforcementLevel: EnforcementLevel;
|
|
150
|
+
}
|
|
151
|
+
export declare const NO_VPN_REQUIREMENTS: VpnRequirements;
|
|
152
|
+
/** The feature id of the strict egress policy: the helper enforces a job's
|
|
153
|
+
* `egress` allow-list and echoes its hash, and the agent verifies the echo. A
|
|
154
|
+
* tunnel with `strictEgress` requires it (see `vpnTunnelRequirements`). */
|
|
155
|
+
export declare const EGRESS_POLICY_FEATURE = "egress-policy-v1";
|
|
156
|
+
/**
|
|
157
|
+
* The slice of a tunnel `vpnTunnelRequirements` reads: the status, plus the two
|
|
158
|
+
* settings that make a tunnel demand something of the Mac (migrations/0045).
|
|
159
|
+
* The columns are required (not optional) so a caller that builds this from a
|
|
160
|
+
* query cannot forget them: a forgotten setting would silently mean "nothing
|
|
161
|
+
* required".
|
|
162
|
+
*/
|
|
163
|
+
export interface VpnTunnelRequirementSource {
|
|
164
|
+
status: "pending" | "ready" | "degraded" | "disabled" | null;
|
|
165
|
+
/** `VpnTunnel.strictEgress` (0/1). */
|
|
166
|
+
strictEgress: number | boolean | null;
|
|
167
|
+
/** `VpnTunnel.complianceMode` ('standard' | 'regulated'). */
|
|
168
|
+
complianceMode: string | null;
|
|
169
|
+
/** The build's app uses an ssh git remote (`isSshGitRemote(App.repoUrl)`, vpn-ssh.ts, migrations/0048).
|
|
170
|
+
* Required, like the two settings above, so a caller cannot forget it: the clone would then run on a Mac
|
|
171
|
+
* that cannot do ssh. Only an exact `true` means "ssh"; the callers compute it from the app row. */
|
|
172
|
+
sshRepo: boolean;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* What the given tunnel requires of an agent. This is the single place a tunnel
|
|
176
|
+
* setting makes a requirement; the scheduler, the guarded claim SQL, the lease
|
|
177
|
+
* response (`job.requires`) and the agent's abort check all consume this one
|
|
178
|
+
* answer.
|
|
179
|
+
*
|
|
180
|
+
* - `strictEgress`: the Mac must report `egress-policy-v1`, because an agent or
|
|
181
|
+
* helper that predates the policy ignores the `egress` block and would run
|
|
182
|
+
* the build with no allow-list. Only an exact `0`/`false` means "not strict":
|
|
183
|
+
* a missing, null or unrecognised value requires it (fail closed).
|
|
184
|
+
* - `complianceMode` other than exactly `standard` (regulated, or a value this
|
|
185
|
+
* code does not know): strict egress as above, and at least `sandbox`
|
|
186
|
+
* enforcement. No Mac reports a sandbox yet, so a regulated tunnel's builds
|
|
187
|
+
* wait for one rather than run unconfined.
|
|
188
|
+
*
|
|
189
|
+
* - `sshRepo` (the app's repository is an ssh remote, PR-G): the Mac must report
|
|
190
|
+
* `ssh-v1`, because an agent or helper that predates it cannot clone through
|
|
191
|
+
* the tunnel's pinned-host-key ssh path and would ignore the ssh fields.
|
|
192
|
+
*
|
|
193
|
+
* A standard-mode tunnel with strict egress off and an https repository requires
|
|
194
|
+
* nothing, exactly as before, so the server can ship ahead of any agent.
|
|
195
|
+
*/
|
|
196
|
+
export declare function vpnTunnelRequirements(tunnel: VpnTunnelRequirementSource): VpnRequirements;
|
|
197
|
+
/** The wire form of requirements, carried in the lease response as
|
|
198
|
+
* `job.requires`: feature ids plus `enforcement:<level>` for a level above
|
|
199
|
+
* `none`. An agent aborts a job with any token it cannot satisfy. */
|
|
200
|
+
export declare function vpnRequirementTokens(req: VpnRequirements): string[];
|
|
201
|
+
/**
|
|
202
|
+
* Which of `tokens` the capabilities do NOT satisfy. A token is satisfied only
|
|
203
|
+
* if it is a feature id the capabilities list, or `enforcement:<level>` with a
|
|
204
|
+
* KNOWN level the capabilities reach. An unknown token, an unknown level, or a
|
|
205
|
+
* non-string entry is unmet: this is what makes the check fail closed against a
|
|
206
|
+
* requirement introduced by a newer control plane than the agent. A non-array
|
|
207
|
+
* `tokens` is itself unmet (reported as one entry).
|
|
208
|
+
*
|
|
209
|
+
* Used by both the scheduler (`agentSatisfiesVpnRequirements`) and the agent
|
|
210
|
+
* (before it starts a leased VPN job), so they cannot disagree.
|
|
211
|
+
*/
|
|
212
|
+
export declare function unmetRequirements(tokens: unknown, capabilities: {
|
|
213
|
+
features: readonly string[];
|
|
214
|
+
enforcementLevel: unknown;
|
|
215
|
+
}): string[];
|
|
216
|
+
/**
|
|
217
|
+
* Operator taint (`Agent.dedicatedOrgId`): a Mac dedicated to an org takes only
|
|
218
|
+
* that org's builds, VPN or not. `null` = shared. An empty string is NOT shared:
|
|
219
|
+
* only an explicit null is (a mangled value must not widen who may build here).
|
|
220
|
+
*/
|
|
221
|
+
export declare function agentAllowsOrg(dedicatedOrgId: string | null, buildOrgId: string): boolean;
|
|
222
|
+
/**
|
|
223
|
+
* Where a tunnel's builds may run (`VpnTunnel.hostPolicy`, migrations/0047).
|
|
224
|
+
* - `shared` any approved, VPN-capable Mac, under the exclusive-host lock
|
|
225
|
+
* (today's behaviour, and the default).
|
|
226
|
+
* - `pinned` only Macs the operator pinned to the tunnel (node affinity).
|
|
227
|
+
* - `dedicated` pinned, AND the Mac is dedicated to this org
|
|
228
|
+
* (`Agent.dedicatedOrgId`, so no other org's work is leased to
|
|
229
|
+
* it), AND it reports no co-hosted GitHub Actions runner.
|
|
230
|
+
* Set only by a platform admin; org admins can read it, never write it.
|
|
231
|
+
*/
|
|
232
|
+
export declare const VPN_HOST_POLICIES: readonly ["shared", "pinned", "dedicated"];
|
|
233
|
+
export type VpnHostPolicy = (typeof VPN_HOST_POLICIES)[number];
|
|
234
|
+
/**
|
|
235
|
+
* What a customer sees for their tunnel's host policy (read-only; the operator
|
|
236
|
+
* sets it). Deliberately plain about the limits: "dedicated" is a scheduling
|
|
237
|
+
* guarantee about which machines AppForge runs this org's VPN builds on, not a
|
|
238
|
+
* claim of hardware isolation or of any compliance certification.
|
|
239
|
+
*/
|
|
240
|
+
export declare function describeVpnHostPolicy(policy: VpnHostPolicy | string, pinnedHostCount?: number): string;
|
|
241
|
+
/** How long a queued VPN build of a `pinned`/`dedicated` tunnel waits for a ready
|
|
242
|
+
* pinned Mac before the control plane's sweep fails it as an infra failure with a
|
|
243
|
+
* refund. The wait is measured as CONTINUOUS host unavailability, not build age:
|
|
244
|
+
* the sweep records when the tunnel's pinned Macs stopped being ready
|
|
245
|
+
* (`VpnTunnelHostWait`) and fails a build only once they have all been unready
|
|
246
|
+
* for this long AND the build itself has been queued this long. Any tick that
|
|
247
|
+
* finds a ready Mac (busy counts as ready) restarts the clock, so a Mac that is
|
|
248
|
+
* briefly away (a reboot or self-update cycle, a short drain) does not fail a
|
|
249
|
+
* build that has merely been waiting in line. Longer than the 120 s
|
|
250
|
+
* lock-reservation window. The sweep runs every 5 minutes, so the effective
|
|
251
|
+
* wait is this plus up to one cron interval. */
|
|
252
|
+
export declare const VPN_HOST_WAIT_SECONDS = 600;
|
|
253
|
+
/** If the sweep's last observation of a tunnel's pinned hosts is older than this
|
|
254
|
+
* (three cron ticks), it knows nothing about the gap, so the next "unready"
|
|
255
|
+
* observation starts a fresh unavailability stretch instead of extending an old one. */
|
|
256
|
+
export declare const VPN_HOST_OBSERVATION_GAP_SECONDS = 900;
|
|
257
|
+
/**
|
|
258
|
+
* Parses a stored policy. An UNKNOWN value is `dedicated`, the strictest, not
|
|
259
|
+
* `shared`: a mangled policy must tighten placement, never loosen it (the
|
|
260
|
+
* column's CHECK makes this unreachable today; it is here so a future value
|
|
261
|
+
* read by an older Worker fails closed).
|
|
262
|
+
*/
|
|
263
|
+
export declare function parseVpnHostPolicy(raw: unknown): VpnHostPolicy;
|
|
264
|
+
/**
|
|
265
|
+
* Whether an agent may serve a VPN build under the tunnel's host policy.
|
|
266
|
+
* - `hostPinned` is "this Mac is in the tunnel's pin set".
|
|
267
|
+
* - `dedicatedOrgId` is the Mac's operator taint.
|
|
268
|
+
* - `coHostedRunner` is what its last (fresh) heartbeat reported: only an
|
|
269
|
+
* exact 0 means "no GitHub Actions runner on the machine". 1, null
|
|
270
|
+
* (unreported) and anything else all refuse for `dedicated`.
|
|
271
|
+
* `shared` always passes (and is the only case that needs no pin).
|
|
272
|
+
*/
|
|
273
|
+
export declare function agentSatisfiesHostPolicy(policy: VpnHostPolicy | string, orgId: string, agent: {
|
|
274
|
+
hostPinned: boolean;
|
|
275
|
+
dedicatedOrgId: string | null;
|
|
276
|
+
coHostedRunner: number | null;
|
|
277
|
+
}): boolean;
|
|
101
278
|
/**
|
|
102
279
|
* The VPN-related slice of an agent row the scheduler needs — kept as its
|
|
103
280
|
* own structural type (not added to `AgentInfo`) because it comes straight
|
|
@@ -112,6 +289,21 @@ export interface VpnSchedulingAgent {
|
|
|
112
289
|
vpnApproved: number;
|
|
113
290
|
/** The org this Mac is exclusively locked to, or null when unlocked. */
|
|
114
291
|
vpnLockOrgId: string | null;
|
|
292
|
+
/** Features the agent proved it can enforce — the caller passes `[]` unless
|
|
293
|
+
* the heartbeat that reported them is fresh (see `agentProvesVpnEnforcement`). */
|
|
294
|
+
vpnFeatures: readonly string[];
|
|
295
|
+
/** Same provenance and freshness rule as `vpnFeatures`. */
|
|
296
|
+
enforcementLevel: EnforcementLevel;
|
|
297
|
+
/** Operator-set taint (migrations/0043): the one org this Mac may serve, or
|
|
298
|
+
* null for a shared Mac. Required (not optional) so a caller can never
|
|
299
|
+
* forget it: forgetting it would silently un-taint a dedicated Mac. */
|
|
300
|
+
dedicatedOrgId: string | null;
|
|
301
|
+
/** What the last heartbeat reported about a co-hosted GitHub Actions runner
|
|
302
|
+
* (migrations/0047): 1 present, 0 absent, null unknown. As with
|
|
303
|
+
* `vpnFeatures`, the caller passes null unless that heartbeat is fresh.
|
|
304
|
+
* Required so a caller cannot forget it (forgetting would let a dedicated
|
|
305
|
+
* policy pass on a stale value). */
|
|
306
|
+
coHostedRunner: number | null;
|
|
115
307
|
}
|
|
116
308
|
/** The slice of a queued-build candidate (`@appforge/db`'s
|
|
117
309
|
* `QueuedBuildCandidate`) the VPN predicate reads. */
|
|
@@ -120,17 +312,34 @@ export interface VpnSchedulingBuild {
|
|
|
120
312
|
useVpn: boolean;
|
|
121
313
|
/** The build's org's tunnel status, or null when it has no tunnel. */
|
|
122
314
|
vpnTunnelStatus: "pending" | "ready" | "degraded" | "disabled" | null;
|
|
315
|
+
/** What the build's tunnel requires of the Mac (`vpnTunnelRequirements`).
|
|
316
|
+
* Ignored for a build that doesn't use the VPN. */
|
|
317
|
+
requirements: VpnRequirements;
|
|
318
|
+
/** The build's tunnel's host policy (`VpnTunnel.hostPolicy`, migrations/0047).
|
|
319
|
+
* Ignored for a build that doesn't use the VPN. */
|
|
320
|
+
hostPolicy: VpnHostPolicy;
|
|
321
|
+
/** Whether the asking agent is pinned to the build's tunnel
|
|
322
|
+
* (`VpnTunnelHostPin`). Required: forgetting it must read as "not pinned". */
|
|
323
|
+
agentPinned: boolean;
|
|
123
324
|
}
|
|
124
325
|
/**
|
|
125
326
|
* Scheduler predicate for the site-to-site VPN's exclusive-host lock —
|
|
126
327
|
* the "taint/toleration" check, ANDed alongside `agentCanBuildPlatform` in
|
|
127
328
|
* `/agents/lease-job` before any `claimBuildById` attempt:
|
|
128
329
|
*
|
|
330
|
+
* - A Mac dedicated to an org (`dedicatedOrgId`) refuses every other org's
|
|
331
|
+
* builds, VPN or not.
|
|
129
332
|
* - A build that doesn't use the VPN is never offered to a Mac that is
|
|
130
333
|
* locked to an org (the lock means "only that org's VPN builds here").
|
|
131
334
|
* - A VPN build needs a VPN-capable AND admin-approved Mac (issue #643), a tunnel that exists and isn't
|
|
132
335
|
* `disabled`, and a Mac that is either unlocked or already locked to
|
|
133
336
|
* this same org (same-org VPN builds share the lock).
|
|
337
|
+
* - A VPN build also needs a Mac that satisfies the tunnel's requirements:
|
|
338
|
+
* it reports every required feature and a high enough enforcement level
|
|
339
|
+
* (capability negotiation, migrations/0043). Nothing is required today.
|
|
340
|
+
* - A VPN build whose tunnel is `pinned` or `dedicated` (migrations/0047) only
|
|
341
|
+
* goes to a Mac pinned to that tunnel, and `dedicated` additionally needs the
|
|
342
|
+
* Mac dedicated to the org with no co-hosted GitHub Actions runner.
|
|
134
343
|
*
|
|
135
344
|
* This is an optimization, not the safety net: it filters on the agent
|
|
136
345
|
* row loaded at the start of the request, which can be stale by the time
|
|
@@ -141,6 +350,36 @@ export interface VpnSchedulingBuild {
|
|
|
141
350
|
* proves it works.
|
|
142
351
|
*/
|
|
143
352
|
export declare function agentSatisfiesVpnRequirements(agent: VpnSchedulingAgent, build: VpnSchedulingBuild): boolean;
|
|
353
|
+
/** The first agent release that can enforce a VPN job (it ships the signed
|
|
354
|
+
* WireGuard helper and honours `job.vpn`). Anything older IGNORES `job.vpn`
|
|
355
|
+
* and would run the build with no tunnel, so it must never be leased one,
|
|
356
|
+
* whatever its (possibly stale) `Agent.vpnCapable` row says. */
|
|
357
|
+
export declare const MIN_VPN_AGENT_VERSION = "0.2.0";
|
|
358
|
+
/** How recent `Agent.lastSeenAt` must be for `vpnCapable` to count: the flag
|
|
359
|
+
* is only as good as the heartbeat that last set it. Three missed 15s beats
|
|
360
|
+
* plus slack — and well under the 60s-ish scheduler cadence's tolerance. */
|
|
361
|
+
export declare const VPN_CAPABILITY_MAX_AGE_SECONDS = 120;
|
|
362
|
+
/**
|
|
363
|
+
* Numeric `major.minor.patch` comparison (a `-prerelease`/`+build` suffix is
|
|
364
|
+
* ignored). Returns <0, 0, >0, or `null` when either side isn't a plain
|
|
365
|
+
* semver — callers treat `null` as "unknown", never as "new enough".
|
|
366
|
+
*/
|
|
367
|
+
export declare function compareSemver(a: string, b: string): number | null;
|
|
368
|
+
/** True only for a known version >= `MIN_VPN_AGENT_VERSION`. A missing or
|
|
369
|
+
* unparseable version is NOT capable (fail closed). */
|
|
370
|
+
export declare function agentVersionSupportsVpn(agentVersion: string | null | undefined): boolean;
|
|
371
|
+
/**
|
|
372
|
+
* Server-side proof that the leasing agent can actually enforce a VPN job,
|
|
373
|
+
* independent of the sticky-ish `vpnCapable` bit: the agent's reported
|
|
374
|
+
* version must be >= `MIN_VPN_AGENT_VERSION` and its last heartbeat (which is
|
|
375
|
+
* what (re-)asserts `vpnCapable`) must be recent. ANDed with
|
|
376
|
+
* `agentSatisfiesVpnRequirements` in `/agents/lease-job`; the guarded SQL in
|
|
377
|
+
* `claimBuildById`/`acquireVpnLock` re-checks freshness and pins the version.
|
|
378
|
+
*/
|
|
379
|
+
export declare function agentProvesVpnEnforcement(agent: {
|
|
380
|
+
agentVersion: string | null;
|
|
381
|
+
lastSeenAt: string;
|
|
382
|
+
}, now?: Date): boolean;
|
|
144
383
|
/**
|
|
145
384
|
* The currently published packages/agent build — bump this in lockstep
|
|
146
385
|
* with packages/agent/package.json's own "version" field (and
|
|
@@ -154,7 +393,7 @@ export declare function agentSatisfiesVpnRequirements(agent: VpnSchedulingAgent,
|
|
|
154
393
|
* version against this same constant to decide whether to auto-issue an
|
|
155
394
|
* `UPDATE_AGENT` command (Phase 2's remote command, reused unchanged).
|
|
156
395
|
*/
|
|
157
|
-
export declare const CURRENT_AGENT_VERSION = "0.
|
|
396
|
+
export declare const CURRENT_AGENT_VERSION = "0.3.1";
|
|
158
397
|
/**
|
|
159
398
|
* Whether a fleet member is running the latest known agent build.
|
|
160
399
|
* `null` — deliberately not "outdated" — for a row with no reported
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-readiness.d.ts","sourceRoot":"","sources":["../src/agent-readiness.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-readiness.d.ts","sourceRoot":"","sources":["../src/agent-readiness.ts"],"names":[],"mappings":"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"}
|
package/dist/agent-readiness.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SSH_FEATURE } from "./vpn-ssh.js";
|
|
1
2
|
/**
|
|
2
3
|
* A mini's `status` column is only ever written as a side effect of an
|
|
3
4
|
* inbound heartbeat (see updateAgentHeartbeat in packages/db/src/agents.ts)
|
|
@@ -100,16 +101,256 @@ export function agentFitsPlatform(readiness, platform) {
|
|
|
100
101
|
export function agentCanBuildPlatform(agent, platform, now = new Date()) {
|
|
101
102
|
return agentFitsPlatform(computeAgentReadiness(agent, now), platform);
|
|
102
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Build-isolation level an agent reports (`Agent.enforcementLevel`,
|
|
106
|
+
* migrations/0043). Ordered: a higher level satisfies a lower requirement.
|
|
107
|
+
* - `none` no kernel-enforced isolation of build processes (today).
|
|
108
|
+
* - `sandbox` builds run under a kernel sandbox profile (Seatbelt).
|
|
109
|
+
* - `sandbox_uid` ... and as a dedicated unprivileged user.
|
|
110
|
+
* Anything unrecognised ranks as `none`: an unknown level never satisfies a
|
|
111
|
+
* requirement (fail closed).
|
|
112
|
+
*/
|
|
113
|
+
export const ENFORCEMENT_LEVELS = ["none", "sandbox", "sandbox_uid"];
|
|
114
|
+
export function parseEnforcementLevel(raw) {
|
|
115
|
+
return typeof raw === "string" && ENFORCEMENT_LEVELS.includes(raw) ? raw : "none";
|
|
116
|
+
}
|
|
117
|
+
/** 0 (`none`) .. 2 (`sandbox_uid`); unknown input is 0. */
|
|
118
|
+
export function enforcementLevelRank(level) {
|
|
119
|
+
return ENFORCEMENT_LEVELS.indexOf(parseEnforcementLevel(level));
|
|
120
|
+
}
|
|
121
|
+
/** What a feature id may look like (`Agent.vpnFeatures` entries, tunnel
|
|
122
|
+
* `requires` entries): lowercase, short, no whitespace or separators that
|
|
123
|
+
* could be abused in a log line or SQL JSON. Ids carry their own version
|
|
124
|
+
* suffix (`ssh-v1`) so a breaking change is a NEW id, never a redefinition. */
|
|
125
|
+
export const VPN_FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
126
|
+
/** An agent reports at most this many features; the rest are dropped. */
|
|
127
|
+
export const MAX_VPN_FEATURES = 32;
|
|
128
|
+
/**
|
|
129
|
+
* Feature ids the regulated-VPN program will introduce. Informational: the
|
|
130
|
+
* scheduler's superset check works on whatever ids a tunnel requires and an
|
|
131
|
+
* agent reports, so a new id needs no change here. Only `egress-policy-v1` is
|
|
132
|
+
* required (by a strict-egress tunnel) and reported (by an agent with a new
|
|
133
|
+
* enough verified helper) so far; the others land in later PRs.
|
|
134
|
+
*/
|
|
135
|
+
export const KNOWN_VPN_FEATURES = ["egress-policy-v1", "ssh-v1", "sandbox-v1", "evidence-v1"];
|
|
136
|
+
/** Prefix of a requirement token that asks for a minimum enforcement level
|
|
137
|
+
* (`enforcement:sandbox`) rather than a feature id. */
|
|
138
|
+
export const ENFORCEMENT_REQUIREMENT_PREFIX = "enforcement:";
|
|
139
|
+
/**
|
|
140
|
+
* The canonical form of a reported feature list: only well-formed ids,
|
|
141
|
+
* de-duplicated, sorted, capped at `MAX_VPN_FEATURES`. Anything else is
|
|
142
|
+
* dropped, never repaired. This is the ONLY form stored in
|
|
143
|
+
* `Agent.vpnFeatures`, so the scheduler's SQL superset check can compare
|
|
144
|
+
* strings exactly.
|
|
145
|
+
*/
|
|
146
|
+
export function canonicalVpnFeatures(input) {
|
|
147
|
+
if (!Array.isArray(input))
|
|
148
|
+
return [];
|
|
149
|
+
const ok = new Set();
|
|
150
|
+
for (const v of input)
|
|
151
|
+
if (typeof v === "string" && VPN_FEATURE_ID_PATTERN.test(v))
|
|
152
|
+
ok.add(v);
|
|
153
|
+
return [...ok].sort().slice(0, MAX_VPN_FEATURES);
|
|
154
|
+
}
|
|
155
|
+
/** Parses the `Agent.vpnFeatures` JSON column. Malformed, non-array or null
|
|
156
|
+
* input is `[]` (no features), never an exception. */
|
|
157
|
+
export function parseVpnFeatures(raw) {
|
|
158
|
+
if (!raw)
|
|
159
|
+
return [];
|
|
160
|
+
try {
|
|
161
|
+
return canonicalVpnFeatures(JSON.parse(raw));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export const NO_VPN_REQUIREMENTS = { features: [], minEnforcementLevel: "none" };
|
|
168
|
+
/** The feature id of the strict egress policy: the helper enforces a job's
|
|
169
|
+
* `egress` allow-list and echoes its hash, and the agent verifies the echo. A
|
|
170
|
+
* tunnel with `strictEgress` requires it (see `vpnTunnelRequirements`). */
|
|
171
|
+
export const EGRESS_POLICY_FEATURE = "egress-policy-v1";
|
|
172
|
+
/**
|
|
173
|
+
* What the given tunnel requires of an agent. This is the single place a tunnel
|
|
174
|
+
* setting makes a requirement; the scheduler, the guarded claim SQL, the lease
|
|
175
|
+
* response (`job.requires`) and the agent's abort check all consume this one
|
|
176
|
+
* answer.
|
|
177
|
+
*
|
|
178
|
+
* - `strictEgress`: the Mac must report `egress-policy-v1`, because an agent or
|
|
179
|
+
* helper that predates the policy ignores the `egress` block and would run
|
|
180
|
+
* the build with no allow-list. Only an exact `0`/`false` means "not strict":
|
|
181
|
+
* a missing, null or unrecognised value requires it (fail closed).
|
|
182
|
+
* - `complianceMode` other than exactly `standard` (regulated, or a value this
|
|
183
|
+
* code does not know): strict egress as above, and at least `sandbox`
|
|
184
|
+
* enforcement. No Mac reports a sandbox yet, so a regulated tunnel's builds
|
|
185
|
+
* wait for one rather than run unconfined.
|
|
186
|
+
*
|
|
187
|
+
* - `sshRepo` (the app's repository is an ssh remote, PR-G): the Mac must report
|
|
188
|
+
* `ssh-v1`, because an agent or helper that predates it cannot clone through
|
|
189
|
+
* the tunnel's pinned-host-key ssh path and would ignore the ssh fields.
|
|
190
|
+
*
|
|
191
|
+
* A standard-mode tunnel with strict egress off and an https repository requires
|
|
192
|
+
* nothing, exactly as before, so the server can ship ahead of any agent.
|
|
193
|
+
*/
|
|
194
|
+
export function vpnTunnelRequirements(tunnel) {
|
|
195
|
+
const regulated = tunnel.complianceMode !== "standard";
|
|
196
|
+
const strict = regulated || (tunnel.strictEgress !== 0 && tunnel.strictEgress !== false);
|
|
197
|
+
const features = [];
|
|
198
|
+
if (strict)
|
|
199
|
+
features.push(EGRESS_POLICY_FEATURE);
|
|
200
|
+
if (tunnel.sshRepo === true)
|
|
201
|
+
features.push(SSH_FEATURE);
|
|
202
|
+
if (features.length === 0)
|
|
203
|
+
return NO_VPN_REQUIREMENTS;
|
|
204
|
+
return { features, minEnforcementLevel: regulated ? "sandbox" : "none" };
|
|
205
|
+
}
|
|
206
|
+
/** The wire form of requirements, carried in the lease response as
|
|
207
|
+
* `job.requires`: feature ids plus `enforcement:<level>` for a level above
|
|
208
|
+
* `none`. An agent aborts a job with any token it cannot satisfy. */
|
|
209
|
+
export function vpnRequirementTokens(req) {
|
|
210
|
+
// Verbatim, deliberately NOT `canonicalVpnFeatures`: that drops malformed ids
|
|
211
|
+
// and truncates at MAX_VPN_FEATURES, which is right for untrusted agent-reported
|
|
212
|
+
// input but would silently remove a REQUIRED feature here (fail open). A
|
|
213
|
+
// malformed or surplus required id must survive so `unmetRequirements` flags it
|
|
214
|
+
// as unmet — matching the guarded SQL, which binds the raw list.
|
|
215
|
+
const tokens = [...new Set(req.features)].sort();
|
|
216
|
+
if (enforcementLevelRank(req.minEnforcementLevel) > 0)
|
|
217
|
+
tokens.push(`${ENFORCEMENT_REQUIREMENT_PREFIX}${req.minEnforcementLevel}`);
|
|
218
|
+
return tokens;
|
|
219
|
+
}
|
|
220
|
+
/** Printable, bounded rendering of an untrusted token for logs and errors. */
|
|
221
|
+
function describeToken(token) {
|
|
222
|
+
if (typeof token !== "string")
|
|
223
|
+
return "<non-string>";
|
|
224
|
+
return token.replace(/[^\x21-\x7e]/g, "?").slice(0, 80);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Which of `tokens` the capabilities do NOT satisfy. A token is satisfied only
|
|
228
|
+
* if it is a feature id the capabilities list, or `enforcement:<level>` with a
|
|
229
|
+
* KNOWN level the capabilities reach. An unknown token, an unknown level, or a
|
|
230
|
+
* non-string entry is unmet: this is what makes the check fail closed against a
|
|
231
|
+
* requirement introduced by a newer control plane than the agent. A non-array
|
|
232
|
+
* `tokens` is itself unmet (reported as one entry).
|
|
233
|
+
*
|
|
234
|
+
* Used by both the scheduler (`agentSatisfiesVpnRequirements`) and the agent
|
|
235
|
+
* (before it starts a leased VPN job), so they cannot disagree.
|
|
236
|
+
*/
|
|
237
|
+
export function unmetRequirements(tokens, capabilities) {
|
|
238
|
+
if (!Array.isArray(tokens))
|
|
239
|
+
return ["<malformed requires>"];
|
|
240
|
+
const have = new Set(capabilities.features);
|
|
241
|
+
const haveRank = enforcementLevelRank(capabilities.enforcementLevel);
|
|
242
|
+
const unmet = [];
|
|
243
|
+
for (const token of tokens) {
|
|
244
|
+
if (typeof token === "string" && token.startsWith(ENFORCEMENT_REQUIREMENT_PREFIX)) {
|
|
245
|
+
const level = token.slice(ENFORCEMENT_REQUIREMENT_PREFIX.length);
|
|
246
|
+
const known = ENFORCEMENT_LEVELS.includes(level);
|
|
247
|
+
if (!known || haveRank < enforcementLevelRank(level))
|
|
248
|
+
unmet.push(describeToken(token));
|
|
249
|
+
}
|
|
250
|
+
else if (typeof token !== "string" || !VPN_FEATURE_ID_PATTERN.test(token) || !have.has(token)) {
|
|
251
|
+
unmet.push(describeToken(token));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return unmet;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Operator taint (`Agent.dedicatedOrgId`): a Mac dedicated to an org takes only
|
|
258
|
+
* that org's builds, VPN or not. `null` = shared. An empty string is NOT shared:
|
|
259
|
+
* only an explicit null is (a mangled value must not widen who may build here).
|
|
260
|
+
*/
|
|
261
|
+
export function agentAllowsOrg(dedicatedOrgId, buildOrgId) {
|
|
262
|
+
return dedicatedOrgId === null || dedicatedOrgId === buildOrgId;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Where a tunnel's builds may run (`VpnTunnel.hostPolicy`, migrations/0047).
|
|
266
|
+
* - `shared` any approved, VPN-capable Mac, under the exclusive-host lock
|
|
267
|
+
* (today's behaviour, and the default).
|
|
268
|
+
* - `pinned` only Macs the operator pinned to the tunnel (node affinity).
|
|
269
|
+
* - `dedicated` pinned, AND the Mac is dedicated to this org
|
|
270
|
+
* (`Agent.dedicatedOrgId`, so no other org's work is leased to
|
|
271
|
+
* it), AND it reports no co-hosted GitHub Actions runner.
|
|
272
|
+
* Set only by a platform admin; org admins can read it, never write it.
|
|
273
|
+
*/
|
|
274
|
+
export const VPN_HOST_POLICIES = ["shared", "pinned", "dedicated"];
|
|
275
|
+
/**
|
|
276
|
+
* What a customer sees for their tunnel's host policy (read-only; the operator
|
|
277
|
+
* sets it). Deliberately plain about the limits: "dedicated" is a scheduling
|
|
278
|
+
* guarantee about which machines AppForge runs this org's VPN builds on, not a
|
|
279
|
+
* claim of hardware isolation or of any compliance certification.
|
|
280
|
+
*/
|
|
281
|
+
export function describeVpnHostPolicy(policy, pinnedHostCount = 0) {
|
|
282
|
+
const n = `${pinnedHostCount} build machine${pinnedHostCount === 1 ? "" : "s"}`;
|
|
283
|
+
switch (parseVpnHostPolicy(policy)) {
|
|
284
|
+
case "shared":
|
|
285
|
+
return "shared: builds may run on any approved AppForge build machine, one organization's VPN tunnel at a time";
|
|
286
|
+
case "pinned":
|
|
287
|
+
return `pinned: VPN builds run only on the ${n} AppForge assigned to your tunnel`;
|
|
288
|
+
case "dedicated":
|
|
289
|
+
return `dedicated: VPN builds run only on the ${n} AppForge assigned to your tunnel, which take no other organization's builds. This is a scheduling arrangement, not a compliance certification`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** How long a queued VPN build of a `pinned`/`dedicated` tunnel waits for a ready
|
|
293
|
+
* pinned Mac before the control plane's sweep fails it as an infra failure with a
|
|
294
|
+
* refund. The wait is measured as CONTINUOUS host unavailability, not build age:
|
|
295
|
+
* the sweep records when the tunnel's pinned Macs stopped being ready
|
|
296
|
+
* (`VpnTunnelHostWait`) and fails a build only once they have all been unready
|
|
297
|
+
* for this long AND the build itself has been queued this long. Any tick that
|
|
298
|
+
* finds a ready Mac (busy counts as ready) restarts the clock, so a Mac that is
|
|
299
|
+
* briefly away (a reboot or self-update cycle, a short drain) does not fail a
|
|
300
|
+
* build that has merely been waiting in line. Longer than the 120 s
|
|
301
|
+
* lock-reservation window. The sweep runs every 5 minutes, so the effective
|
|
302
|
+
* wait is this plus up to one cron interval. */
|
|
303
|
+
export const VPN_HOST_WAIT_SECONDS = 600;
|
|
304
|
+
/** If the sweep's last observation of a tunnel's pinned hosts is older than this
|
|
305
|
+
* (three cron ticks), it knows nothing about the gap, so the next "unready"
|
|
306
|
+
* observation starts a fresh unavailability stretch instead of extending an old one. */
|
|
307
|
+
export const VPN_HOST_OBSERVATION_GAP_SECONDS = 900;
|
|
308
|
+
/**
|
|
309
|
+
* Parses a stored policy. An UNKNOWN value is `dedicated`, the strictest, not
|
|
310
|
+
* `shared`: a mangled policy must tighten placement, never loosen it (the
|
|
311
|
+
* column's CHECK makes this unreachable today; it is here so a future value
|
|
312
|
+
* read by an older Worker fails closed).
|
|
313
|
+
*/
|
|
314
|
+
export function parseVpnHostPolicy(raw) {
|
|
315
|
+
return typeof raw === "string" && VPN_HOST_POLICIES.includes(raw) ? raw : "dedicated";
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether an agent may serve a VPN build under the tunnel's host policy.
|
|
319
|
+
* - `hostPinned` is "this Mac is in the tunnel's pin set".
|
|
320
|
+
* - `dedicatedOrgId` is the Mac's operator taint.
|
|
321
|
+
* - `coHostedRunner` is what its last (fresh) heartbeat reported: only an
|
|
322
|
+
* exact 0 means "no GitHub Actions runner on the machine". 1, null
|
|
323
|
+
* (unreported) and anything else all refuse for `dedicated`.
|
|
324
|
+
* `shared` always passes (and is the only case that needs no pin).
|
|
325
|
+
*/
|
|
326
|
+
export function agentSatisfiesHostPolicy(policy, orgId, agent) {
|
|
327
|
+
const p = parseVpnHostPolicy(policy);
|
|
328
|
+
if (p === "shared")
|
|
329
|
+
return true;
|
|
330
|
+
if (!agent.hostPinned)
|
|
331
|
+
return false;
|
|
332
|
+
if (p === "pinned")
|
|
333
|
+
return true;
|
|
334
|
+
return agent.dedicatedOrgId === orgId && agent.coHostedRunner === 0;
|
|
335
|
+
}
|
|
103
336
|
/**
|
|
104
337
|
* Scheduler predicate for the site-to-site VPN's exclusive-host lock —
|
|
105
338
|
* the "taint/toleration" check, ANDed alongside `agentCanBuildPlatform` in
|
|
106
339
|
* `/agents/lease-job` before any `claimBuildById` attempt:
|
|
107
340
|
*
|
|
341
|
+
* - A Mac dedicated to an org (`dedicatedOrgId`) refuses every other org's
|
|
342
|
+
* builds, VPN or not.
|
|
108
343
|
* - A build that doesn't use the VPN is never offered to a Mac that is
|
|
109
344
|
* locked to an org (the lock means "only that org's VPN builds here").
|
|
110
345
|
* - A VPN build needs a VPN-capable AND admin-approved Mac (issue #643), a tunnel that exists and isn't
|
|
111
346
|
* `disabled`, and a Mac that is either unlocked or already locked to
|
|
112
347
|
* this same org (same-org VPN builds share the lock).
|
|
348
|
+
* - A VPN build also needs a Mac that satisfies the tunnel's requirements:
|
|
349
|
+
* it reports every required feature and a high enough enforcement level
|
|
350
|
+
* (capability negotiation, migrations/0043). Nothing is required today.
|
|
351
|
+
* - A VPN build whose tunnel is `pinned` or `dedicated` (migrations/0047) only
|
|
352
|
+
* goes to a Mac pinned to that tunnel, and `dedicated` additionally needs the
|
|
353
|
+
* Mac dedicated to the org with no co-hosted GitHub Actions runner.
|
|
113
354
|
*
|
|
114
355
|
* This is an optimization, not the safety net: it filters on the agent
|
|
115
356
|
* row loaded at the start of the request, which can be stale by the time
|
|
@@ -120,14 +361,76 @@ export function agentCanBuildPlatform(agent, platform, now = new Date()) {
|
|
|
120
361
|
* proves it works.
|
|
121
362
|
*/
|
|
122
363
|
export function agentSatisfiesVpnRequirements(agent, build) {
|
|
364
|
+
if (!agentAllowsOrg(agent.dedicatedOrgId, build.orgId))
|
|
365
|
+
return false;
|
|
123
366
|
if (!build.useVpn)
|
|
124
367
|
return agent.vpnLockOrgId === null;
|
|
125
368
|
if (agent.vpnCapable !== 1 || agent.vpnApproved !== 1)
|
|
126
369
|
return false;
|
|
127
370
|
if (build.vpnTunnelStatus === null || build.vpnTunnelStatus === "disabled")
|
|
128
371
|
return false;
|
|
372
|
+
if (unmetRequirements(vpnRequirementTokens(build.requirements), { features: agent.vpnFeatures, enforcementLevel: agent.enforcementLevel }).length > 0)
|
|
373
|
+
return false;
|
|
374
|
+
if (!agentSatisfiesHostPolicy(build.hostPolicy, build.orgId, {
|
|
375
|
+
hostPinned: build.agentPinned,
|
|
376
|
+
dedicatedOrgId: agent.dedicatedOrgId,
|
|
377
|
+
coHostedRunner: agent.coHostedRunner,
|
|
378
|
+
})) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
129
381
|
return agent.vpnLockOrgId === null || agent.vpnLockOrgId === build.orgId;
|
|
130
382
|
}
|
|
383
|
+
/** The first agent release that can enforce a VPN job (it ships the signed
|
|
384
|
+
* WireGuard helper and honours `job.vpn`). Anything older IGNORES `job.vpn`
|
|
385
|
+
* and would run the build with no tunnel, so it must never be leased one,
|
|
386
|
+
* whatever its (possibly stale) `Agent.vpnCapable` row says. */
|
|
387
|
+
export const MIN_VPN_AGENT_VERSION = "0.2.0";
|
|
388
|
+
/** How recent `Agent.lastSeenAt` must be for `vpnCapable` to count: the flag
|
|
389
|
+
* is only as good as the heartbeat that last set it. Three missed 15s beats
|
|
390
|
+
* plus slack — and well under the 60s-ish scheduler cadence's tolerance. */
|
|
391
|
+
export const VPN_CAPABILITY_MAX_AGE_SECONDS = 120;
|
|
392
|
+
/**
|
|
393
|
+
* Numeric `major.minor.patch` comparison (a `-prerelease`/`+build` suffix is
|
|
394
|
+
* ignored). Returns <0, 0, >0, or `null` when either side isn't a plain
|
|
395
|
+
* semver — callers treat `null` as "unknown", never as "new enough".
|
|
396
|
+
*/
|
|
397
|
+
export function compareSemver(a, b) {
|
|
398
|
+
const parse = (v) => {
|
|
399
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
|
|
400
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
401
|
+
};
|
|
402
|
+
const pa = parse(a);
|
|
403
|
+
const pb = parse(b);
|
|
404
|
+
if (!pa || !pb)
|
|
405
|
+
return null;
|
|
406
|
+
for (let i = 0; i < 3; i++) {
|
|
407
|
+
if (pa[i] !== pb[i])
|
|
408
|
+
return pa[i] - pb[i];
|
|
409
|
+
}
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
/** True only for a known version >= `MIN_VPN_AGENT_VERSION`. A missing or
|
|
413
|
+
* unparseable version is NOT capable (fail closed). */
|
|
414
|
+
export function agentVersionSupportsVpn(agentVersion) {
|
|
415
|
+
if (!agentVersion)
|
|
416
|
+
return false;
|
|
417
|
+
const cmp = compareSemver(agentVersion, MIN_VPN_AGENT_VERSION);
|
|
418
|
+
return cmp !== null && cmp >= 0;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Server-side proof that the leasing agent can actually enforce a VPN job,
|
|
422
|
+
* independent of the sticky-ish `vpnCapable` bit: the agent's reported
|
|
423
|
+
* version must be >= `MIN_VPN_AGENT_VERSION` and its last heartbeat (which is
|
|
424
|
+
* what (re-)asserts `vpnCapable`) must be recent. ANDed with
|
|
425
|
+
* `agentSatisfiesVpnRequirements` in `/agents/lease-job`; the guarded SQL in
|
|
426
|
+
* `claimBuildById`/`acquireVpnLock` re-checks freshness and pins the version.
|
|
427
|
+
*/
|
|
428
|
+
export function agentProvesVpnEnforcement(agent, now = new Date()) {
|
|
429
|
+
if (!agentVersionSupportsVpn(agent.agentVersion))
|
|
430
|
+
return false;
|
|
431
|
+
const ageMs = now.getTime() - parseUtc(agent.lastSeenAt).getTime();
|
|
432
|
+
return Number.isFinite(ageMs) && ageMs <= VPN_CAPABILITY_MAX_AGE_SECONDS * 1000;
|
|
433
|
+
}
|
|
131
434
|
/**
|
|
132
435
|
* The currently published packages/agent build — bump this in lockstep
|
|
133
436
|
* with packages/agent/package.json's own "version" field (and
|
|
@@ -141,7 +444,7 @@ export function agentSatisfiesVpnRequirements(agent, build) {
|
|
|
141
444
|
* version against this same constant to decide whether to auto-issue an
|
|
142
445
|
* `UPDATE_AGENT` command (Phase 2's remote command, reused unchanged).
|
|
143
446
|
*/
|
|
144
|
-
export const CURRENT_AGENT_VERSION = "0.
|
|
447
|
+
export const CURRENT_AGENT_VERSION = "0.3.1";
|
|
145
448
|
/**
|
|
146
449
|
* Whether a fleet member is running the latest known agent build.
|
|
147
450
|
* `null` — deliberately not "outdated" — for a row with no reported
|