@zgeoff/atc 2.24.0 → 2.25.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.
@@ -1,6 +1,7 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
2
  import { LineDecoder } from '../protocol/line-decoder';
3
3
  import { isCompiledBinary } from '../shared/is-compiled-binary';
4
+ import type { BrokerAuthHost } from './broker-auth-host';
4
5
  import { buildImpName } from './build-imp-name';
5
6
  import { buildTarArchive } from './build-tar-archive';
6
7
  import type {
@@ -96,6 +97,8 @@ export class ImpProvider implements ExecutionProvider {
96
97
  // namespace an impd token's imp patterns are checked against.
97
98
  readonly impPrefix: string;
98
99
 
100
+ readonly brokerAuth: BrokerAuthHost;
101
+
99
102
  private readonly port: ImpPort;
100
103
 
101
104
  private readonly target: ImpTargetOptions;
@@ -106,6 +109,14 @@ export class ImpProvider implements ExecutionProvider {
106
109
 
107
110
  private readonly hosts = new Map<string, ImpHost>();
108
111
 
112
+ // Each imp's readyings, sleeps, and lease returns run one after another,
113
+ // in the order they were asked for, so none of them gives back the lease
114
+ // a readying just took.
115
+ private readonly turns = new Map<string, Promise<void>>();
116
+
117
+ // The readyings of each imp still waiting for their turn.
118
+ private readonly waitingPrepares = new Map<string, number>();
119
+
109
120
  private readonly atcBinary: string | null;
110
121
 
111
122
  // Whether impd carries output offsets, once a prepare has read its
@@ -132,6 +143,19 @@ export class ImpProvider implements ExecutionProvider {
132
143
  dir,
133
144
  atc: target.guestATC ?? (this.atcBinary === null ? null : `${dir}/bin/atc`),
134
145
  };
146
+
147
+ this.brokerAuth = {
148
+ impPrefix: this.impPrefix,
149
+ port,
150
+ getImpName: (hostKey) => this.getImpName(hostKey),
151
+ createImp: (hostKey) =>
152
+ port.createImp({
153
+ name: this.getImpName(hostKey),
154
+ ...(target.image === undefined ? {} : { image: target.image }),
155
+ ...(target.memoryMib === undefined ? {} : { memoryMib: target.memoryMib }),
156
+ }),
157
+ destroyImp: (hostKey) => this.destroyHost(hostKey),
158
+ };
135
159
  }
136
160
 
137
161
  // The imp a host key runs on, under this provider's prefix.
@@ -141,7 +165,25 @@ export class ImpProvider implements ExecutionProvider {
141
165
 
142
166
  // Creates the host's imp when impd holds none, then takes the daemon's
143
167
  // lease, which boots or wakes the imp.
144
- readonly prepareHost = async (request: HostRequest): Promise<void> => {
168
+ readonly prepareHost = (request: HostRequest): Promise<void> => {
169
+ const name = this.getImpName(request.host);
170
+
171
+ this.waitingPrepares.set(name, (this.waitingPrepares.get(name) ?? 0) + 1);
172
+
173
+ return this.withHostTurn(name, () => {
174
+ const left = (this.waitingPrepares.get(name) ?? 1) - 1;
175
+
176
+ if (left === 0) {
177
+ this.waitingPrepares.delete(name);
178
+ } else {
179
+ this.waitingPrepares.set(name, left);
180
+ }
181
+
182
+ return this.prepareHostInTurn(request);
183
+ });
184
+ };
185
+
186
+ private readonly prepareHostInTurn = async (request: HostRequest): Promise<void> => {
145
187
  const name = this.getImpName(request.host);
146
188
  const label = `atc-${request.daemonID}`;
147
189
 
@@ -187,9 +229,11 @@ export class ImpProvider implements ExecutionProvider {
187
229
  harnesses: 0,
188
230
  renewTimer: null,
189
231
  suspending: false,
232
+ isIdle: null,
190
233
  };
191
234
 
192
235
  host.suspending = false;
236
+ host.isIdle = request.isIdle ?? null;
193
237
 
194
238
  this.hosts.set(request.host, host);
195
239
  this.startRenewal(host, label);
@@ -201,6 +245,7 @@ export class ImpProvider implements ExecutionProvider {
201
245
  harnesses: 0,
202
246
  renewTimer: null,
203
247
  suspending: false,
248
+ isIdle: null,
204
249
  };
205
250
 
206
251
  this.hosts.set(spec.host, host);
@@ -225,11 +270,13 @@ export class ImpProvider implements ExecutionProvider {
225
270
  cwd: spec.cwd,
226
271
  cols: spec.cols,
227
272
  rows: spec.rows,
273
+ ...(spec.requireBroker === true ? { require: ['broker'] } : {}),
228
274
  },
229
275
  {
230
276
  offsets: this.offsets,
231
277
  reconnectDelaysMs: this.reconnectDelaysMs,
232
278
  ...(reporter === null ? {} : { ready: reporter.forward.listening }),
279
+ ...(spec.admit === undefined ? {} : { admit: spec.admit }),
233
280
  isSuspending: () => host.suspending,
234
281
  onDone: () => {
235
282
  reporter?.forward.stop();
@@ -271,37 +318,57 @@ export class ImpProvider implements ExecutionProvider {
271
318
  // then asks impd to sleep the imp without force. Another owner's lease
272
319
  // refuses the sleep: the daemon takes its lease back and the refusal
273
320
  // carries what impd showed of the other leases.
274
- readonly suspendHost = async (hostKey: string): Promise<void> => {
275
- const host = this.hosts.get(hostKey) ?? {
276
- name: this.getImpName(hostKey),
277
- harnesses: 0,
278
- renewTimer: null,
279
- suspending: false,
280
- };
321
+ readonly suspendHost = (hostKey: string, isIdle?: () => boolean): Promise<void> =>
322
+ this.withHostTurn(this.getImpName(hostKey), async () => {
323
+ const host = this.hosts.get(hostKey) ?? {
324
+ name: this.getImpName(hostKey),
325
+ harnesses: 0,
326
+ renewTimer: null,
327
+ suspending: false,
328
+ isIdle: null,
329
+ };
330
+
331
+ this.hosts.set(hostKey, host);
332
+
333
+ // A host a harness runs on, or that a readying waits for, is busy.
334
+ const isBusy = () =>
335
+ isIdle !== undefined &&
336
+ (!isIdle() || host.harnesses > 0 || (this.waitingPrepares.get(host.name) ?? 0) > 0);
337
+
338
+ if (isBusy()) {
339
+ throw buildBusyRefusal(host.name);
340
+ }
281
341
 
282
- this.hosts.set(hostKey, host);
283
- this.stopRenewal(host);
342
+ this.stopRenewal(host);
284
343
 
285
- host.suspending = true;
344
+ host.suspending = true;
286
345
 
287
- try {
288
- if (this.label !== null) {
289
- await this.port.releaseLease(host.name, this.label);
290
- }
346
+ try {
347
+ if (this.label !== null) {
348
+ await this.port.releaseLease(host.name, this.label);
349
+ }
291
350
 
292
- await this.port.suspendImp(host.name);
293
- } catch (error) {
294
- host.suspending = false;
351
+ if (isBusy()) {
352
+ host.suspending = false;
295
353
 
296
- if (error instanceof ImpPortError && error.code === 'LEASED') {
297
- await this.restoreLease(host);
354
+ await this.restoreLease(host);
298
355
 
299
- throw buildLeasedRefusal(host.name, error.data);
300
- }
356
+ throw buildBusyRefusal(host.name);
357
+ }
301
358
 
302
- throw toHostRefusal(error, host.name);
303
- }
304
- };
359
+ await this.port.suspendImp(host.name);
360
+ } catch (error) {
361
+ host.suspending = false;
362
+
363
+ if (error instanceof ImpPortError && error.code === 'LEASED') {
364
+ await this.restoreLease(host);
365
+
366
+ throw buildLeasedRefusal(host.name, error.data);
367
+ }
368
+
369
+ throw toHostRefusal(error, host.name);
370
+ }
371
+ });
305
372
 
306
373
  // A destroy ends every lease on the imp with it.
307
374
  readonly destroyHost = async (hostKey: string): Promise<void> => {
@@ -437,13 +504,7 @@ export class ImpProvider implements ExecutionProvider {
437
504
 
438
505
  host.renewTimer = setInterval(
439
506
  () => {
440
- if (host.harnesses === 0) {
441
- void this.tryReleaseLease(host);
442
-
443
- return;
444
- }
445
-
446
- void this.tryRenewLease(host, label);
507
+ void this.refreshLease(host, label);
447
508
  },
448
509
  (this.leaseSeconds * 1000) / 3,
449
510
  );
@@ -473,6 +534,28 @@ export class ImpProvider implements ExecutionProvider {
473
534
  }
474
535
  }
475
536
 
537
+ // Runs a readying, a sleep, or a lease return of an imp after the one
538
+ // before it there. A turn never starts in its caller's tick, so a harness
539
+ // end the daemon still applies counts before the turn checks the host.
540
+ private async withHostTurn<T>(name: string, run: () => Promise<T>): Promise<T> {
541
+ const before = this.turns.get(name) ?? Promise.resolve();
542
+ const turn = Promise.withResolvers<void>();
543
+
544
+ this.turns.set(name, turn.promise);
545
+
546
+ try {
547
+ await before;
548
+
549
+ return await run();
550
+ } finally {
551
+ turn.resolve();
552
+
553
+ if (this.turns.get(name) === turn.promise) {
554
+ this.turns.delete(name);
555
+ }
556
+ }
557
+ }
558
+
476
559
  private async restoreLease(host: ImpHost): Promise<void> {
477
560
  if (this.label === null) {
478
561
  return;
@@ -487,19 +570,45 @@ export class ImpProvider implements ExecutionProvider {
487
570
  this.startRenewal(host, this.label);
488
571
  }
489
572
 
490
- private async tryReleaseLease(host: ImpHost): Promise<boolean> {
491
- this.stopRenewal(host);
573
+ // A host with no harness gives its lease back, unless a launch readies it,
574
+ // which keeps the lease renewed.
575
+ private async refreshLease(host: ImpHost, label: string): Promise<void> {
576
+ if (host.harnesses === 0) {
577
+ const released = await this.tryReleaseLease(host);
492
578
 
493
- if (this.label === null) {
494
- return false;
579
+ if (released) {
580
+ return;
581
+ }
495
582
  }
496
583
 
497
- try {
498
- return await this.port.releaseLease(host.name, this.label);
499
- } catch {
500
- return false;
584
+ if (host.renewTimer !== null) {
585
+ await this.tryRenewLease(host, label);
501
586
  }
502
587
  }
588
+
589
+ // Gives an imp's lease back once nothing runs or starts there, in the
590
+ // imp's turn, so it never drops a lease a readying took for a launch.
591
+ private tryReleaseLease(host: ImpHost): Promise<boolean> {
592
+ return this.withHostTurn(host.name, async () => {
593
+ const isBusy =
594
+ host.harnesses > 0 ||
595
+ host.suspending ||
596
+ (this.waitingPrepares.get(host.name) ?? 0) > 0 ||
597
+ host.isIdle?.() === false;
598
+
599
+ if (isBusy || this.label === null) {
600
+ return false;
601
+ }
602
+
603
+ this.stopRenewal(host);
604
+
605
+ try {
606
+ return await this.port.releaseLease(host.name, this.label);
607
+ } catch {
608
+ return false;
609
+ }
610
+ });
611
+ }
503
612
  }
504
613
 
505
614
  // What the provider follows of one imp: its name, how many harnesses run
@@ -510,6 +619,10 @@ interface ImpHost {
510
619
  harnesses: number;
511
620
  renewTimer: ReturnType<typeof setInterval> | null;
512
621
  suspending: boolean;
622
+
623
+ // The daemon's check that nothing runs or starts on the host, from its
624
+ // latest readying; null until one gives it.
625
+ isIdle: (() => boolean) | null;
513
626
  }
514
627
 
515
628
  function buildImpSessionName(sessionID: string): string {
@@ -571,6 +684,14 @@ function buildGuestEnv(env: Readonly<Record<string, string>>): Record<string, st
571
684
  return { TERM: 'xterm-256color', LANG: 'C.UTF-8', PATH: GUEST_PATH, ...own };
572
685
  }
573
686
 
687
+ function buildBusyRefusal(name: string): DaemonError {
688
+ return new DaemonError(
689
+ 'host_unavailable',
690
+ `imp ${name} stays awake: a session runs there or is starting there`,
691
+ { provider: 'imp', problem: 'busy' },
692
+ );
693
+ }
694
+
574
695
  function buildLeasedRefusal(name: string, data: unknown): DaemonError {
575
696
  const record = typeof data === 'object' && data !== null ? data : {};
576
697
  const leases = 'leases' in record && Array.isArray(record.leases) ? record.leases : [];
@@ -1,5 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { spawn } from 'bun-pty';
3
+ import { DaemonError } from '../protocol/daemon-error';
3
4
  import { collectCleanEnv } from '../shared/collect-clean-env';
4
5
  import type {
5
6
  CommandResult,
@@ -39,7 +40,17 @@ export class LocalPTYProvider implements ExecutionProvider {
39
40
 
40
41
  // The daemon's machine keeps no process the daemon lets go of, so a
41
42
  // detach ends the harness as a kill does, after dropping every listener.
43
+ // It has no credential broker, so a harness that requires one never
44
+ // starts here.
42
45
  readonly spawnHarness = (spec: HarnessSpec): HarnessHandle => {
46
+ if (spec.requireBroker === true) {
47
+ throw new DaemonError(
48
+ 'auth_target_unsupported',
49
+ "the daemon's own machine has no credential broker to start the harness behind",
50
+ { provider: 'local-pty' },
51
+ );
52
+ }
53
+
43
54
  const pty = spawn(spec.bin, [...spec.args], {
44
55
  name: 'xterm-256color',
45
56
  cols: spec.cols,
@@ -185,7 +185,8 @@ function isSameSession(s: Session, entry: FleetEntry): boolean {
185
185
 
186
186
  // The refusals that leave one session without a terminal: its target is
187
187
  // misconfigured, gone from the config, changed, has no provider here, or
188
- // cannot start a terminal, or its agent refuses every start.
188
+ // cannot start a terminal, or its agent refuses every start, or its
189
+ // host's runtime auth binding refuses the launch, its broker included.
189
190
  const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
190
191
  'unsupported_operation',
191
192
  'unknown_target',
@@ -195,6 +196,19 @@ const REFUSED_ADOPT_CODES: ReadonlySet<string> = new Set([
195
196
  'host_unavailable',
196
197
  'auth_not_configured',
197
198
  'auth_target_unsupported',
199
+ 'auth_impd_too_old',
200
+ 'auth_token_scope',
201
+ 'auth_token_too_broad',
202
+ 'auth_imp_out_of_scope',
203
+ 'auth_secret_not_grantable',
204
+ 'auth_secret_mismatch',
205
+ 'auth_runtime_mismatch',
206
+ 'auth_grant_missing',
207
+ 'auth_grants_mismatch',
208
+ 'auth_rebind_required',
209
+ 'auth_blocked',
210
+ 'auth_binding_invalid',
211
+ 'broker_not_ready',
198
212
  ]);
199
213
 
200
214
  async function tryAdoptTerminal(