Repository navigation
Expand file tree
/
Copy pathProviderInstanceRegistry.ts
More file actions
566 lines (527 loc) · 21.7 KB
/
Copy pathProviderInstanceRegistry.ts
File metadata and controls
566 lines (527 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
import {
providerInstanceConfigEnabledFlag,
ProviderInstanceId,
type ProviderInstanceConfig,
type ProviderInstanceConfigMap,
type ProviderInstanceEnvironment,
type ProviderDriverKind,
type ServerProvider,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import { buildUnavailableProviderSnapshot } from "./unavailableProviderSnapshot.ts";
import * as ProviderInstanceRegistryMutator from "./ProviderInstanceRegistryMutator.ts";
import * as ProviderSecretResolver from "./ProviderSecretResolver.ts";
import type { AnyProviderDriver, ProviderInstance } from "@t3tools/provider-core/server/driver";
export class ProviderInstanceRegistry extends Context.Service<
ProviderInstanceRegistry,
{
readonly getInstance: (
instanceId: ProviderInstanceId,
) => Effect.Effect<ProviderInstance | undefined>;
readonly listInstances: Effect.Effect<ReadonlyArray<ProviderInstance>>;
readonly listUnavailable: Effect.Effect<ReadonlyArray<ServerProvider>>;
/**
* The configured environment of every instance `rebuildInstanceWhen` can act on: live ones
* and ones whose last build failed. Lets a caller resolve every external secret before a
* batch of rebuilds instead of once per rebuild.
*/
readonly listEnvironments: Effect.Effect<
ReadonlyMap<ProviderInstanceId, ProviderInstanceEnvironment | undefined>
>;
/**
* Rebuilds one instance from the configuration it already has when `shouldRebuild` accepts
* it, for inputs a driver reads at create time that change without the settings file
* changing (a credential in an external secret store), which `reconcile`'s diff cannot see.
* The instance keeps its settings-author position, an unknown id is a no-op, and sessions
* already using the old instance keep it. Resolves to whether a rebuild happened.
*/
readonly rebuildInstanceWhen: (
instanceId: ProviderInstanceId,
shouldRebuild: (entry: ProviderInstanceConfig) => boolean,
) => Effect.Effect<boolean>;
/**
* NOTE: because `Stream.fromPubSub` defers `PubSub.subscribe` until the stream starts
* running, forking a consumer via `Stream.runForEach(...).pipe(Effect.forkScoped)` races
* the next publish — the forked fiber may not have subscribed yet when the publish lands.
* Hot-reload consumers that must not miss a publish should use `subscribeChanges` below
* instead, which acquires the subscription synchronously in the caller's fiber before the
* consumer loop is forked.
*/
readonly streamChanges: Stream.Stream<void>;
readonly subscribeChanges: Effect.Effect<PubSub.Subscription<void>, never, Scope.Scope>;
}
>()("t3/provider/ProviderInstanceRegistry") {}
interface LiveEntry {
readonly instance: ProviderInstance;
readonly scope: Scope.Closeable;
readonly entry: ProviderInstanceConfig;
}
function withoutKey<V>(
map: ReadonlyMap<ProviderInstanceId, V>,
instanceId: ProviderInstanceId,
): ReadonlyMap<ProviderInstanceId, V> {
if (!map.has(instanceId)) {
return map;
}
const next = new Map(map);
next.delete(instanceId);
return next;
}
/**
* Reinstate a rebuilt instance where it used to sit. Map iteration order is
* settings-author order all the way out to the provider list, so appending a
* rebuilt instance would shuffle the UI every time a secret is refreshed.
*
* An instance that is not in `previousEntries` was never live to begin with
* (a rebuild that recovers a previously unavailable instance), so it has no
* position to return to and goes last until the next settings change.
*/
function withInstanceAt(
previousEntries: ReadonlyMap<ProviderInstanceId, LiveEntry>,
instanceId: ProviderInstanceId,
rebuilt: LiveEntry,
): ReadonlyMap<ProviderInstanceId, LiveEntry> {
const next = new Map<ProviderInstanceId, LiveEntry>();
for (const [id, existing] of previousEntries) {
next.set(id, id === instanceId ? rebuilt : existing);
}
next.set(instanceId, rebuilt);
return next;
}
interface RegistryState {
readonly entries: Ref.Ref<ReadonlyMap<ProviderInstanceId, LiveEntry>>;
readonly unavailable: Ref.Ref<ReadonlyMap<ProviderInstanceId, ServerProvider>>;
readonly changes: PubSub.PubSub<void>;
}
const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boolean =>
Equal.equals(a, b);
/**
* An explicit false on either the envelope or the raw config blob wins (most
* restrictive) — old settings files can carry both flags with conflicting
* values, and a user's disable must never be silently undone.
*/
const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => {
const rawConfigEnabled = providerInstanceConfigEnabledFlag(entry.config);
if (entry.enabled === false || rawConfigEnabled === false) {
return false;
}
return entry.enabled ?? providerInstanceConfigEnabledFlag(typedConfig) ?? true;
};
/**
* What a provider process inherits from the server. A variable whose secret
* could not be read is removed rather than left to the server's own value,
* because that would run the provider as an account the user did not pick.
*/
function hostEnvironmentWithout(
hostEnvironment: NodeJS.ProcessEnv,
names: ReadonlyArray<string>,
): NodeJS.ProcessEnv {
if (names.length === 0) {
return hostEnvironment;
}
const next: NodeJS.ProcessEnv = { ...hostEnvironment };
for (const name of names) {
delete next[name];
}
return next;
}
const buildEntry = <R>(input: {
readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
readonly parentScope: Scope.Scope;
readonly secretResolver: ProviderSecretResolver.ProviderSecretResolverShape;
readonly hostEnvironment: NodeJS.ProcessEnv;
readonly instanceId: ProviderInstanceId;
readonly rawInstanceId: string;
readonly entry: ProviderInstanceConfig;
}): Effect.Effect<
| { readonly kind: "live"; readonly live: LiveEntry }
| { readonly kind: "unavailable"; readonly snapshot: ServerProvider },
never,
R
> =>
Effect.gen(function* () {
const {
driversById,
parentScope,
secretResolver,
hostEnvironment,
instanceId,
rawInstanceId,
entry,
} = input;
const driver = driversById.get(entry.driver);
if (!driver) {
return {
kind: "unavailable" as const,
snapshot: yield* buildUnavailableProviderSnapshot({
driverKind: entry.driver,
instanceId,
displayName: entry.displayName,
accentColor: entry.accentColor,
reason: `Driver '${entry.driver}' is not registered in this build.`,
}),
};
}
const decoder = Schema.decodeUnknownEffect(driver.configSchema);
const decodeResult = yield* decoder(entry.config ?? driver.defaultConfig()).pipe(Effect.result);
if (decodeResult._tag === "Failure") {
const issue = decodeResult.failure;
const detail = issue.message ?? String(issue);
yield* Effect.logError("Failed to decode provider instance config", {
instanceId: rawInstanceId,
driver: entry.driver,
detail,
});
return {
kind: "unavailable" as const,
snapshot: yield* buildUnavailableProviderSnapshot({
driverKind: entry.driver,
instanceId,
displayName: entry.displayName,
accentColor: entry.accentColor,
reason: `Invalid config for instance '${rawInstanceId}': ${detail}`,
}),
};
}
const typedConfig = decodeResult.success;
const resolvedEnvironment = yield* secretResolver.resolve(entry.environment);
const childScope = yield* Scope.make();
yield* Scope.addFinalizer(parentScope, Scope.close(childScope, Exit.void).pipe(Effect.ignore));
const createResult = yield* driver
.create({
instanceId,
displayName: entry.displayName,
accentColor: entry.accentColor,
environment: resolvedEnvironment.variables,
enabled: resolveEntryEnabled(entry, typedConfig),
config: typedConfig,
})
.pipe(
Effect.provideService(Scope.Scope, childScope),
Effect.provideService(
HostProcess.Environment,
hostEnvironmentWithout(hostEnvironment, resolvedEnvironment.unresolved),
),
Effect.result,
);
if (createResult._tag === "Failure") {
yield* Effect.logError("Failed to create provider instance", {
instanceId: rawInstanceId,
driver: entry.driver,
detail: createResult.failure.detail,
});
yield* Scope.close(childScope, Exit.void).pipe(Effect.ignore);
return {
kind: "unavailable" as const,
snapshot: yield* buildUnavailableProviderSnapshot({
driverKind: entry.driver,
instanceId,
displayName: entry.displayName,
accentColor: entry.accentColor,
reason: `Driver '${entry.driver}' failed to create instance: ${createResult.failure.detail}`,
}),
};
}
return {
kind: "live" as const,
live: {
instance: createResult.success,
scope: childScope,
entry,
},
};
});
const makeReconcile = <R>(input: {
readonly state: RegistryState;
readonly driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>;
readonly parentScope: Scope.Scope;
readonly secretResolver: ProviderSecretResolver.ProviderSecretResolverShape;
readonly hostEnvironment: NodeJS.ProcessEnv;
}): ((configMap: ProviderInstanceConfigMap) => Effect.Effect<void, never, R>) => {
const { state, driversById, parentScope, secretResolver, hostEnvironment } = input;
return (configMap: ProviderInstanceConfigMap) =>
Effect.gen(function* () {
const previousEntries = yield* Ref.get(state.entries);
const previousUnavailable = yield* Ref.get(state.unavailable);
const nextRaw = Object.entries(configMap);
const nextKeys = new Set<ProviderInstanceId>(
nextRaw.map(([raw]) => ProviderInstanceId.make(raw)),
);
// 1. Close scopes for instances that disappeared or whose config
// changed. Do this BEFORE creating replacements so ids map 1-to-1
// to live scopes at all times.
const removedIds: Array<ProviderInstanceId> = [];
const replacedIds = new Set<ProviderInstanceId>();
for (const [instanceId, live] of previousEntries) {
if (!nextKeys.has(instanceId)) {
removedIds.push(instanceId);
continue;
}
const nextEntry = configMap[instanceId];
if (nextEntry !== undefined && !entryEqual(live.entry, nextEntry)) {
replacedIds.add(instanceId);
}
}
for (const id of [...removedIds, ...replacedIds]) {
const live = previousEntries.get(id);
if (live) {
yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore);
}
}
const builtEntries = new Map<ProviderInstanceId, LiveEntry>();
const builtUnavailable = new Map<ProviderInstanceId, ServerProvider>();
let orderChanged = false;
const previousOrder = [...previousEntries.keys()];
const nextOrder: Array<ProviderInstanceId> = [];
for (const [rawInstanceId, entry] of nextRaw) {
const instanceId = ProviderInstanceId.make(rawInstanceId);
nextOrder.push(instanceId);
const existing = previousEntries.get(instanceId);
if (existing !== undefined && !replacedIds.has(instanceId)) {
builtEntries.set(instanceId, existing);
continue;
}
const result = yield* buildEntry({
driversById,
parentScope,
secretResolver,
hostEnvironment,
instanceId,
rawInstanceId,
entry,
});
if (result.kind === "live") {
builtEntries.set(instanceId, result.live);
} else {
builtUnavailable.set(instanceId, result.snapshot);
}
}
if (previousOrder.length === nextOrder.length) {
for (let i = 0; i < previousOrder.length; i++) {
if (previousOrder[i] !== nextOrder[i]) {
orderChanged = true;
break;
}
}
} else {
orderChanged = true;
}
const entriesChanged =
orderChanged ||
removedIds.length > 0 ||
replacedIds.size > 0 ||
builtEntries.size !== previousEntries.size;
const unavailableChanged =
builtUnavailable.size !== previousUnavailable.size ||
[...builtUnavailable].some(([id, snapshot]) => {
const prev = previousUnavailable.get(id);
return prev === undefined || !Equal.equals(prev, snapshot);
}) ||
[...previousUnavailable].some(([id]) => !builtUnavailable.has(id));
yield* Ref.set(state.entries, builtEntries);
yield* Ref.set(state.unavailable, builtUnavailable);
if (entriesChanged || unavailableChanged) {
yield* PubSub.publish(state.changes, undefined);
}
});
};
export const makeProviderInstanceRegistry = <R>(input: {
readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
readonly configMap: ProviderInstanceConfigMap;
}): Effect.Effect<
{
readonly registry: ProviderInstanceRegistry["Service"];
readonly mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape;
},
never,
R | Scope.Scope
> =>
Effect.gen(function* () {
const driversById = new Map<ProviderDriverKind, AnyProviderDriver<R>>(
input.drivers.map((driver) => [driver.driverKind, driver]),
);
// Capture the enclosing scope so per-instance child scopes can be
// attached to it at `reconcile` time. Without this, `reconcile`
// called later (e.g. from the hydration layer) would attach child
// scopes to the *caller's* scope instead of the registry's.
const parentScope = yield* Scope.Scope;
const driverContext = yield* Effect.context<R>();
const secretResolver = yield* ProviderSecretResolver.ProviderSecretResolver;
const hostEnvironment = yield* HostProcess.Environment;
const entries = yield* Ref.make<ReadonlyMap<ProviderInstanceId, LiveEntry>>(new Map());
const unavailable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ServerProvider>>(new Map());
const changes = yield* PubSub.unbounded<void>();
yield* Effect.addFinalizer(() => PubSub.shutdown(changes));
// Config envelopes for instances a rebuild could not bring back. Without
// this a failed rebuild is a one-way door: the instance leaves `entries`,
// so the next refresh finds nothing live to rebuild and only a settings
// change can restore it.
const rebuildable = yield* Ref.make<ReadonlyMap<ProviderInstanceId, ProviderInstanceConfig>>(
new Map(),
);
// Both mutators read the map, do slow work, then write it back. A secret
// read can park on a person at a biometric prompt, which is more than long
// enough for a settings change to land in the middle, so they take turns.
// Reads stay outside: `getInstance` and `listInstances` never wait.
const mutations = yield* Semaphore.make(1);
const state: RegistryState = { entries, unavailable, changes };
const reconcileWithR = makeReconcile({
state,
driversById,
parentScope,
secretResolver,
hostEnvironment,
});
const reconcile: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape["reconcile"] =
(configMap) =>
mutations.withPermits(1)(
reconcileWithR(configMap).pipe(
Effect.tap(() => Ref.set(rebuildable, new Map())),
Effect.provideContext(driverContext),
),
);
yield* reconcile(input.configMap);
// Rebuild one instance from the config it already has. Unlike
// `reconcile`, which is driven by a settings diff, this is for inputs the
// envelope cannot show, see the shape docs. Order is preserved by
// rewriting the map in place rather than appending the replacement.
const rebuildInstanceWhen: ProviderInstanceRegistry["Service"]["rebuildInstanceWhen"] = (
instanceId,
shouldRebuild,
) =>
mutations.withPermits(1)(
Effect.gen(function* () {
const previousEntries = yield* Ref.get(entries);
const live = previousEntries.get(instanceId);
// An instance a previous rebuild could not restore has no live entry
// to read the config from, so its envelope comes from `rebuildable`.
const entry = live?.entry ?? (yield* Ref.get(rebuildable)).get(instanceId);
if (entry === undefined || !shouldRebuild(entry)) {
return false;
}
// Everything except the build itself is bookkeeping over refs, and it
// is uninterruptible so the instance is never half-moved. Only the
// build is interruptible, because it is the part that can take
// minutes waiting on a secret store.
//
// Remembering the envelope comes first: an interrupt anywhere after
// this point still leaves the instance retryable on the next
// refresh, which is the only recovery path it has left.
yield* Effect.uninterruptible(
Effect.gen(function* () {
yield* Ref.update(rebuildable, (previous) =>
new Map(previous).set(instanceId, entry),
);
if (live === undefined) {
return;
}
// Drop the instance before closing it, or the map hands callers
// a bundle whose scope is gone for the whole build. Its last
// snapshot stands in meanwhile: aggregators treat an id that is
// in neither list as gone and prune it, so leaving the id
// nowhere would blank the provider's card until the build lands.
const parked = yield* live.instance.snapshot.getSnapshot;
yield* Ref.set(entries, withoutKey(previousEntries, instanceId));
yield* Ref.update(unavailable, (previous) =>
new Map(previous).set(instanceId, parked),
);
yield* Scope.close(live.scope, Exit.void).pipe(Effect.ignore);
}),
);
const result = yield* buildEntry({
driversById,
parentScope,
secretResolver,
hostEnvironment,
instanceId,
rawInstanceId: instanceId,
entry,
});
// Uninterruptible as well: the replacement is already running by
// now, and an interrupt that dropped it on the floor would leave a
// provider process nothing can reach or stop.
yield* Effect.uninterruptible(
Effect.gen(function* () {
if (result.kind === "live") {
yield* Ref.set(entries, withInstanceAt(previousEntries, instanceId, result.live));
yield* Ref.update(unavailable, (previous) => withoutKey(previous, instanceId));
yield* Ref.update(rebuildable, (previous) => withoutKey(previous, instanceId));
} else {
yield* Ref.update(unavailable, (previous) =>
new Map(previous).set(instanceId, result.snapshot),
);
}
yield* PubSub.publish(changes, undefined);
}),
);
return true;
}).pipe(Effect.provideContext(driverContext)),
);
const registry: ProviderInstanceRegistry["Service"] = {
getInstance: (id) => Ref.get(entries).pipe(Effect.map((map) => map.get(id)?.instance)),
listInstances: Ref.get(entries).pipe(
Effect.map(
(map) =>
Array.from(map.values(), (live) => live.instance) as ReadonlyArray<ProviderInstance>,
),
),
listUnavailable: Ref.get(unavailable).pipe(
Effect.map((map) => Array.from(map.values()) as ReadonlyArray<ServerProvider>),
),
listEnvironments: Effect.gen(function* () {
const environments = new Map<ProviderInstanceId, ProviderInstanceEnvironment | undefined>();
for (const [instanceId, entry] of yield* Ref.get(rebuildable)) {
environments.set(instanceId, entry.environment);
}
// Live entries are written second so an instance that is both live and
// pending a retry reports the configuration it is actually running.
for (const [instanceId, live] of yield* Ref.get(entries)) {
environments.set(instanceId, live.entry.environment);
}
return environments;
}),
rebuildInstanceWhen,
get streamChanges() {
return Stream.fromPubSub(changes);
},
get subscribeChanges() {
return PubSub.subscribe(changes);
},
};
const mutator: ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutatorShape = {
reconcile,
};
return { registry, mutator };
});
export const layer = <R>(input: {
readonly drivers: ReadonlyArray<AnyProviderDriver<R>>;
readonly configMap: ProviderInstanceConfigMap;
}): Layer.Layer<
ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
never,
R
> =>
Layer.effectContext(
makeProviderInstanceRegistry(input).pipe(
Effect.map(({ registry, mutator }) =>
Context.make(ProviderInstanceRegistry, registry).pipe(
Context.add(ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator, mutator),
),
),
),
) as Layer.Layer<
ProviderInstanceRegistry | ProviderInstanceRegistryMutator.ProviderInstanceRegistryMutator,
never,
R
>;