@homeflare/alchemy 0.48.1 → 0.50.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 +11 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
- package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
- package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
- package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
- package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
- package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/fake-access.d.ts +40 -0
- package/dist/cloudflare/fake-access.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +7 -2
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
- package/dist/cloudflare/fake-tunnel.d.ts +48 -0
- package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +15 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +518 -7
- package/dist/cloudflare/index.js.map +15 -4
- package/dist/cloudflare/providers.d.ts +1 -1
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
- package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
- package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
- package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
- package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
- package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
- package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
- package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
- package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
- package/dist/cloudflare/saas-oidc.d.ts +36 -0
- package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts +44 -0
- package/dist/kubernetes/helm-chart-bounded.d.ts.map +1 -0
- package/dist/kubernetes/index.d.ts +12 -0
- package/dist/kubernetes/index.d.ts.map +1 -0
- package/dist/kubernetes/index.js +585 -0
- package/dist/kubernetes/index.js.map +19 -0
- package/dist/kubernetes/ready-checks.d.ts +45 -0
- package/dist/kubernetes/ready-checks.d.ts.map +1 -0
- package/dist/kubernetes/ready-classify.d.ts +23 -0
- package/dist/kubernetes/ready-classify.d.ts.map +1 -0
- package/dist/kubernetes/ready-connect.d.ts +29 -0
- package/dist/kubernetes/ready-connect.d.ts.map +1 -0
- package/dist/kubernetes/ready-declare.d.ts +5 -0
- package/dist/kubernetes/ready-declare.d.ts.map +1 -0
- package/dist/kubernetes/ready-errors.d.ts +80 -0
- package/dist/kubernetes/ready-errors.d.ts.map +1 -0
- package/dist/kubernetes/ready-poll.d.ts +61 -0
- package/dist/kubernetes/ready-poll.d.ts.map +1 -0
- package/dist/kubernetes/ready.d.ts +74 -0
- package/dist/kubernetes/ready.d.ts.map +1 -0
- package/dist/talos/fake-apiserver.d.ts +11 -2
- package/dist/talos/fake-apiserver.d.ts.map +1 -1
- package/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/kubernetes-ready.md +146 -0
- package/docs/ownership.md +3 -2
- package/docs/peers.md +13 -0
- package/docs/provider-standard.md +7 -0
- package/docs/saas-oidc.md +185 -0
- package/docs/upstream-conformance-families.md +1 -1
- package/docs/upstream-conformance.md +3 -0
- package/package.json +7 -1
- package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
- package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
- package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
- package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
- package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
- package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
- package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
- package/src/cloudflare/cloudflared-tunnel.ts +116 -0
- package/src/cloudflare/fake-access.ts +196 -0
- package/src/cloudflare/fake-mesh.ts +6 -3
- package/src/cloudflare/fake-tunnel.ts +148 -0
- package/src/cloudflare/index.ts +27 -4
- package/src/cloudflare/providers.ts +16 -3
- package/src/cloudflare/saas-oidc-api.ts +224 -0
- package/src/cloudflare/saas-oidc-consumer.ts +35 -0
- package/src/cloudflare/saas-oidc-error.ts +52 -0
- package/src/cloudflare/saas-oidc-form.test.ts +227 -0
- package/src/cloudflare/saas-oidc-form.ts +190 -0
- package/src/cloudflare/saas-oidc-harness.ts +140 -0
- package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
- package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
- package/src/cloudflare/saas-oidc-props.ts +147 -0
- package/src/cloudflare/saas-oidc-read.test.ts +85 -0
- package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
- package/src/cloudflare/saas-oidc-state.test.ts +128 -0
- package/src/cloudflare/saas-oidc-team.test.ts +88 -0
- package/src/cloudflare/saas-oidc-team.ts +70 -0
- package/src/cloudflare/saas-oidc-wire.ts +116 -0
- package/src/cloudflare/saas-oidc.test.ts +201 -0
- package/src/cloudflare/saas-oidc.ts +107 -0
- package/src/kubernetes/helm-chart-bounded.test.ts +242 -0
- package/src/kubernetes/helm-chart-bounded.ts +110 -0
- package/src/kubernetes/index.ts +28 -0
- package/src/kubernetes/ready-backoff.test.ts +124 -0
- package/src/kubernetes/ready-checks.test.ts +106 -0
- package/src/kubernetes/ready-checks.ts +165 -0
- package/src/kubernetes/ready-classify.ts +76 -0
- package/src/kubernetes/ready-connect.ts +52 -0
- package/src/kubernetes/ready-declare.test.ts +85 -0
- package/src/kubernetes/ready-declare.ts +50 -0
- package/src/kubernetes/ready-errors.ts +102 -0
- package/src/kubernetes/ready-first-install.test.ts +126 -0
- package/src/kubernetes/ready-poll-states.test.ts +92 -0
- package/src/kubernetes/ready-poll.test.ts +194 -0
- package/src/kubernetes/ready-poll.ts +215 -0
- package/src/kubernetes/ready-rollout.test.ts +58 -0
- package/src/kubernetes/ready-secrecy.test.ts +131 -0
- package/src/kubernetes/ready.fixtures.ts +103 -0
- package/src/kubernetes/ready.test.ts +205 -0
- package/src/kubernetes/ready.ts +188 -0
- package/src/talos/fake-apiserver.ts +21 -3
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HomeFlare.Access.SaasOidcApplication` — a Cloudflare Access SaaS application that makes Access an
|
|
3
|
+
* OIDC identity provider for one relying party (OpenBao's JWT/OIDC auth, Headlamp, a Kubernetes
|
|
4
|
+
* API server). Usage and the secret rules: docs/saas-oidc.md.
|
|
5
|
+
*
|
|
6
|
+
* 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Access.Application`. Alchemy beta.81's Application has
|
|
7
|
+
* no `saas_app` field (its source mentions `saas` only as a type and in two comments), and
|
|
8
|
+
* `@distilled.cloud/cloudflare` rc.13 decodes a saas app's GET and create response WITHOUT it, so
|
|
9
|
+
* the client id cannot be read back through either. See saas-oidc-api.ts.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THE TYPE ID IS A STATE KEY, AND THIS ONE WAS MOVED, NOT RENAMED. It is the string the
|
|
12
|
+
* homeflare-openbao stack's live `OpenBaoOidcSaas` row was written under. A different string would
|
|
13
|
+
* make the next plan see a new resource and an orphaned one: a second OIDC app, with a new
|
|
14
|
+
* client id and `aud`, and a delete of the live one unless it were retained. `tests` pin it.
|
|
15
|
+
*
|
|
16
|
+
* ★ `retain` BY DEFAULT, like every kit resource whose deletion breaks its consumers: deleting the
|
|
17
|
+
* app deletes the client id every relying party is configured with. The openbao declaration
|
|
18
|
+
* already pipes `RemovalPolicy.retain()`; `delete` stays fully implemented for a declaration
|
|
19
|
+
* that opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
20
|
+
*/
|
|
21
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
22
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
23
|
+
import * as Provider from 'alchemy/Provider';
|
|
24
|
+
import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
25
|
+
import * as Context from 'effect/Context';
|
|
26
|
+
import * as Effect from 'effect/Effect';
|
|
27
|
+
import * as HttpClient from 'effect/http/HttpClient';
|
|
28
|
+
import type { Providers } from './providers.ts';
|
|
29
|
+
import { deleteSaasOidc, readSaasOidc, reconcileSaasOidc } from './saas-oidc-lifecycle.ts';
|
|
30
|
+
import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
|
|
31
|
+
|
|
32
|
+
export interface SaasOidcApplication extends Resource<
|
|
33
|
+
'HomeFlare.Access.SaasOidcApplication',
|
|
34
|
+
SaasOidcApplicationProps,
|
|
35
|
+
SaasOidcApplicationAttributes,
|
|
36
|
+
never,
|
|
37
|
+
Providers
|
|
38
|
+
> {}
|
|
39
|
+
|
|
40
|
+
export const SaasOidcApplication: ResourceClass<SaasOidcApplication> =
|
|
41
|
+
Resource<SaasOidcApplication>('HomeFlare.Access.SaasOidcApplication', {
|
|
42
|
+
defaultRemovalPolicy: 'retain',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const accountId = Effect.gen(function* () {
|
|
46
|
+
const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
|
|
47
|
+
return resolved.accountId;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, as MeshNodeProvider does
|
|
52
|
+
* (mesh-node.ts): correct whether or not the stack also merges `Cloudflare.providers()`.
|
|
53
|
+
*/
|
|
54
|
+
export const SaasOidcApplicationProvider = () =>
|
|
55
|
+
Provider.effect(
|
|
56
|
+
SaasOidcApplication,
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const services = Context.make(
|
|
59
|
+
Cloudflare.CloudflareEnvironment,
|
|
60
|
+
yield* Cloudflare.CloudflareEnvironment,
|
|
61
|
+
).pipe(
|
|
62
|
+
Context.add(Credentials, yield* Credentials),
|
|
63
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
64
|
+
);
|
|
65
|
+
// ★ Only the three captured services are provided; `createPhysicalName` still takes
|
|
66
|
+
// InstanceId, Stack and Stage from the engine at call time, as in every Alchemy provider.
|
|
67
|
+
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
68
|
+
Effect.provideContext(effect, services);
|
|
69
|
+
|
|
70
|
+
return SaasOidcApplication.Provider.of({
|
|
71
|
+
/**
|
|
72
|
+
* ★ THE ISSUER AND EVERY ENDPOINT ARE STABLE. They depend only on the team domain and the
|
|
73
|
+
* client id, and `checkTeam` refuses any team other than the app's own host, so no update
|
|
74
|
+
* of this app can move them. Alchemy's plan (Plan.ts `withStables`, beta.81) keeps ONLY
|
|
75
|
+
* the listed attributes resolved for consumers of an updated resource; without them, a
|
|
76
|
+
* redirect-URI change would leave `app.issuer` unresolved and re-plan every consumer
|
|
77
|
+
* (the Talos KubeAuthenticationConfig, so a kube-apiserver restart on each node).
|
|
78
|
+
* ⚠️ `teamDomain` is NOT listed: state written by the openbao copy has no such attribute,
|
|
79
|
+
* so a consumer would read `undefined` on the first plan after the move.
|
|
80
|
+
*/
|
|
81
|
+
stables: [
|
|
82
|
+
'applicationId',
|
|
83
|
+
'aud',
|
|
84
|
+
'clientId',
|
|
85
|
+
'accountId',
|
|
86
|
+
'issuer',
|
|
87
|
+
'authorizationEndpoint',
|
|
88
|
+
'tokenEndpoint',
|
|
89
|
+
'jwksEndpoint',
|
|
90
|
+
'userinfoEndpoint',
|
|
91
|
+
'configurationEndpoint',
|
|
92
|
+
],
|
|
93
|
+
/**
|
|
94
|
+
* ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Access.Application` already lists
|
|
95
|
+
* every Access application in the account for `alchemy unsafe nuke` (its `list`, saas
|
|
96
|
+
* apps included); a second listing here would make nuke delete each app twice.
|
|
97
|
+
*/
|
|
98
|
+
list: () => Effect.succeed([]),
|
|
99
|
+
nuke: { skip: true },
|
|
100
|
+
read: ({ id, olds, output }) =>
|
|
101
|
+
run(Effect.flatMap(accountId, (account) => readSaasOidc(account, id, olds, output))),
|
|
102
|
+
reconcile: ({ id, news, output }) =>
|
|
103
|
+
run(Effect.flatMap(accountId, (account) => reconcileSaasOidc(account, id, news, output))),
|
|
104
|
+
delete: ({ output }) => run(deleteSaasOidc(output)),
|
|
105
|
+
});
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `boundedHelmChartProvider` THROUGH upstream's real `HelmChartProvider`, the real `talos-openbao`
|
|
3
|
+
* adapter, a fake `bao`, a fake `helm` and the fake apiserver (`talos/fake-apiserver.ts`): a PATCH
|
|
4
|
+
* that never answers must fail reconcile with the typed timeout, a fast apply must return what
|
|
5
|
+
* upstream returns, and the layer must be the DIRECT `Provider(HelmChart)` registration.
|
|
6
|
+
* Deadlines are real (1s): the fake request holds no timer, so nothing stays blocked.
|
|
7
|
+
*/
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { expect, test } from 'bun:test';
|
|
11
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
12
|
+
import { HelmChart, HelmChartProvider } from 'alchemy/Kubernetes/HelmChart';
|
|
13
|
+
import * as Kubernetes from 'alchemy/Kubernetes';
|
|
14
|
+
import * as Provider from 'alchemy/Provider';
|
|
15
|
+
import { AlchemyContext } from 'alchemy/AlchemyContext';
|
|
16
|
+
import { Stack } from 'alchemy/Stack';
|
|
17
|
+
import * as Cause from 'effect/Cause';
|
|
18
|
+
import * as Duration from 'effect/Duration';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Exit from 'effect/Exit';
|
|
21
|
+
import * as Layer from 'effect/Layer';
|
|
22
|
+
import * as Option from 'effect/Option';
|
|
23
|
+
import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
|
|
24
|
+
import { TalosOpenBaoAdapter, talosOpenBaoConnection } from '../talos/cluster-adapter.ts';
|
|
25
|
+
import { UIDS, config, helmRow, session, vault } from '../talos/cluster-adapter.fixtures.ts';
|
|
26
|
+
import { fakeApiServer } from '../talos/fake-apiserver.ts';
|
|
27
|
+
import { type FakeHandler, fakeSpawner } from '../talos/fake-process.ts';
|
|
28
|
+
import {
|
|
29
|
+
HELM_READ_TIMEOUT,
|
|
30
|
+
HELM_RECONCILE_TIMEOUT,
|
|
31
|
+
KubernetesReadyBadDuration,
|
|
32
|
+
boundedHelmChartProvider,
|
|
33
|
+
isBoundedHelmChartProvider,
|
|
34
|
+
} from './index.ts';
|
|
35
|
+
import { parseGoDuration } from './ready.ts';
|
|
36
|
+
import { KubernetesReconcileTimeout } from './helm-chart-bounded.ts';
|
|
37
|
+
|
|
38
|
+
const HOST = 'c1.cluster.invalid';
|
|
39
|
+
const NS_PATH = '/api/v1/namespaces/ns-c1';
|
|
40
|
+
const RENDERED = 'apiVersion: v1\nkind: Namespace\nmetadata:\n name: ns-c1\n';
|
|
41
|
+
|
|
42
|
+
// The fake `helm` answers the render; every other command is the fake vault's.
|
|
43
|
+
const handler: FakeHandler = (call) =>
|
|
44
|
+
call.args[0] === 'template' ? { stdout: RENDERED } : vault(call);
|
|
45
|
+
const spawner = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, fakeSpawner(handler));
|
|
46
|
+
const services = Layer.mergeAll(
|
|
47
|
+
NodeServices.layer,
|
|
48
|
+
TalosOpenBaoAdapter(config).pipe(Layer.provide(spawner)),
|
|
49
|
+
// ⚠️ Last, so the fake spawner shadows NodeServices' real one: no real `helm` or `bao` runs.
|
|
50
|
+
spawner,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Kubernetes.providers() builds workload providers that read the ambient Stack at build time.
|
|
54
|
+
const stack = Layer.mergeAll(
|
|
55
|
+
Layer.succeed(Stack, { name: 'bounded-test', stage: 'test' } as never),
|
|
56
|
+
Layer.succeed(AlchemyContext, {
|
|
57
|
+
adopt: false,
|
|
58
|
+
dev: false,
|
|
59
|
+
dotAlchemy: join(tmpdir(), 'hf-bounded'),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const CHART = 'oci://example.invalid/chart';
|
|
64
|
+
const news = {
|
|
65
|
+
chart: CHART,
|
|
66
|
+
cluster: talosOpenBaoConnection('uid-c1'),
|
|
67
|
+
releaseName: 'r',
|
|
68
|
+
} as never;
|
|
69
|
+
|
|
70
|
+
type Service = Provider.ProviderService<HelmChart>;
|
|
71
|
+
const run = <A, E>(
|
|
72
|
+
// The requirements (spawner, FileSystem, Stack...) are all supplied by `services`/`stack` below.
|
|
73
|
+
provider: Layer.Layer<Provider.Provider<HelmChart>, never, unknown>,
|
|
74
|
+
body: (s: Service) => Effect.Effect<A, E, never>,
|
|
75
|
+
) =>
|
|
76
|
+
Effect.runPromise(
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
return yield* body((yield* Provider.Provider<HelmChart>(HelmChart.Type)) as Service);
|
|
79
|
+
}).pipe(Effect.provide(Layer.provideMerge(provider, services))) as Effect.Effect<A, E>,
|
|
80
|
+
);
|
|
81
|
+
const reconcile = (s: Service) =>
|
|
82
|
+
s.reconcile({ id: 'cilium', news, output: undefined, session } as never);
|
|
83
|
+
|
|
84
|
+
test('a PATCH that never answers fails reconcile with the typed timeout inside the deadline', async () => {
|
|
85
|
+
const api = fakeApiServer(UIDS, [], { [`PATCH ${HOST}${NS_PATH}`]: { hang: true } });
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
// ★ Distinct deadlines: swapping the two (reconcile on the read bound) would wait an hour.
|
|
89
|
+
const failure = await run(boundedHelmChartProvider('1s', '1h'), (s) =>
|
|
90
|
+
reconcile(s).pipe(Effect.flip),
|
|
91
|
+
);
|
|
92
|
+
expect(failure).toBeInstanceOf(KubernetesReconcileTimeout);
|
|
93
|
+
expect(failure).toMatchObject({
|
|
94
|
+
_tag: 'KubernetesReconcileTimeout',
|
|
95
|
+
id: 'cilium',
|
|
96
|
+
operation: 'reconcile',
|
|
97
|
+
seconds: 1,
|
|
98
|
+
});
|
|
99
|
+
expect(Date.now() - started).toBeLessThan(4000);
|
|
100
|
+
expect(api.seen).toContain(`PATCH ${HOST}${NS_PATH}`);
|
|
101
|
+
// The message names the row and the seconds, nothing the server said.
|
|
102
|
+
expect(String(failure)).toBe(
|
|
103
|
+
'KubernetesReconcileTimeout: Kubernetes.HelmChart cilium: reconcile did not finish within 1s (the apiserver stopped answering)',
|
|
104
|
+
);
|
|
105
|
+
} finally {
|
|
106
|
+
api.restore();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a read whose identity GET never answers fails with the typed timeout on the read bound', async () => {
|
|
111
|
+
const api = fakeApiServer(UIDS, [HOST]);
|
|
112
|
+
try {
|
|
113
|
+
const failure = await run(boundedHelmChartProvider('1h', '1s'), (s) =>
|
|
114
|
+
(s.read as NonNullable<Service['read']>)
|
|
115
|
+
.call(s, {
|
|
116
|
+
id: 'cilium',
|
|
117
|
+
olds: undefined,
|
|
118
|
+
output: helmRow({ uid: 'uid-c1' }),
|
|
119
|
+
session,
|
|
120
|
+
} as never)
|
|
121
|
+
.pipe(Effect.flip),
|
|
122
|
+
);
|
|
123
|
+
expect(failure).toMatchObject({
|
|
124
|
+
_tag: 'KubernetesReconcileTimeout',
|
|
125
|
+
operation: 'read',
|
|
126
|
+
seconds: 1,
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
api.restore();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a fast apply returns exactly the attributes upstream returns', async () => {
|
|
134
|
+
const api = fakeApiServer(UIDS);
|
|
135
|
+
try {
|
|
136
|
+
const bounded = await run(boundedHelmChartProvider(), reconcile);
|
|
137
|
+
const upstream = await run(HelmChartProvider(), reconcile);
|
|
138
|
+
expect(bounded).toEqual(upstream);
|
|
139
|
+
expect(bounded).toMatchObject({ releaseName: 'r', namespace: 'default', chart: CHART });
|
|
140
|
+
expect(api.seen.some((call) => call.startsWith('PATCH '))).toBe(true);
|
|
141
|
+
} finally {
|
|
142
|
+
api.restore();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('stables, aliases, diff and delete are upstream by spread', async () => {
|
|
147
|
+
const [bounded, upstream] = await Promise.all([
|
|
148
|
+
run(boundedHelmChartProvider(), (s) => Effect.succeed(s)),
|
|
149
|
+
run(HelmChartProvider(), (s) => Effect.succeed(s)),
|
|
150
|
+
]);
|
|
151
|
+
expect(bounded.stables).toEqual(upstream.stables);
|
|
152
|
+
expect(bounded.aliases).toEqual(upstream.aliases);
|
|
153
|
+
expect(Object.keys(bounded).sort()).toEqual(Object.keys(upstream).sort());
|
|
154
|
+
expect(typeof bounded.diff).toBe('function');
|
|
155
|
+
expect(typeof bounded.delete).toBe('function');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('the deadlines are pinned: reconcile 5m0s, read 1m, and they parse as such', async () => {
|
|
159
|
+
expect(HELM_RECONCILE_TIMEOUT).toBe('5m0s');
|
|
160
|
+
expect(HELM_READ_TIMEOUT).toBe('1m0s');
|
|
161
|
+
const ms = (v: string) =>
|
|
162
|
+
Effect.runPromise(parseGoDuration('x', v)).then((d) => Duration.toMillis(d));
|
|
163
|
+
expect(await ms(HELM_RECONCILE_TIMEOUT)).toBe(300_000);
|
|
164
|
+
expect(await ms(HELM_READ_TIMEOUT)).toBe(60_000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a malformed deadline dies at layer build naming Kubernetes.HelmChart and the field', async () => {
|
|
168
|
+
const dies = async (reconcileTimeout: string, readTimeout: string) => {
|
|
169
|
+
const exit = await Effect.runPromiseExit(
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
return yield* Provider.Provider<HelmChart>(HelmChart.Type);
|
|
172
|
+
}).pipe(
|
|
173
|
+
Effect.provide(
|
|
174
|
+
Layer.provideMerge(boundedHelmChartProvider(reconcileTimeout, readTimeout), services),
|
|
175
|
+
),
|
|
176
|
+
) as Effect.Effect<unknown>,
|
|
177
|
+
);
|
|
178
|
+
return Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
|
|
179
|
+
};
|
|
180
|
+
const reconcileBad = await dies('soon', '1m0s');
|
|
181
|
+
expect(reconcileBad).toBeInstanceOf(KubernetesReadyBadDuration);
|
|
182
|
+
expect((reconcileBad as Error).message).toBe(
|
|
183
|
+
"Kubernetes.HelmChart: reconcileTimeout 'soon' is not a duration like 10m0s",
|
|
184
|
+
);
|
|
185
|
+
const readBad = await dies('5m0s', '0s');
|
|
186
|
+
expect((readBad as Error).message).toBe(
|
|
187
|
+
"Kubernetes.HelmChart: readTimeout '0s' is not a duration like 10m0s",
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('an apiserver refusal passes through body-free (the needle never leaves)', async () => {
|
|
192
|
+
const needle = 'NEEDLE-secret-body-9f3a';
|
|
193
|
+
const api = fakeApiServer(UIDS, [], {
|
|
194
|
+
[`PATCH ${HOST}${NS_PATH}`]: { body: { message: needle }, status: 422 },
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const failure = await run(boundedHelmChartProvider(), (s) => reconcile(s).pipe(Effect.flip));
|
|
198
|
+
expect(failure).toMatchObject({ _tag: 'KubernetesReadyApiError', statusCode: 422 });
|
|
199
|
+
expect(String(failure)).not.toContain(needle);
|
|
200
|
+
expect(JSON.stringify(failure)).not.toContain(needle);
|
|
201
|
+
expect((failure as Error).message).toBe(
|
|
202
|
+
`Kubernetes.Ready: PATCH ${NS_PATH}?fieldManager=alchemy&force=true responded 422`,
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
api.restore();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('isBoundedHelmChartProvider is true for the wrapper and false for upstream', async () => {
|
|
210
|
+
const [bounded, upstream] = await Promise.all([
|
|
211
|
+
run(boundedHelmChartProvider(), (s) => Effect.succeed(s)),
|
|
212
|
+
run(HelmChartProvider(), (s) => Effect.succeed(s)),
|
|
213
|
+
]);
|
|
214
|
+
expect(isBoundedHelmChartProvider(bounded)).toBe(true);
|
|
215
|
+
expect(isBoundedHelmChartProvider(upstream)).toBe(false);
|
|
216
|
+
expect(isBoundedHelmChartProvider(undefined)).toBe(false);
|
|
217
|
+
expect(isBoundedHelmChartProvider({ ...bounded })).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('merged beside Kubernetes.providers() the engine-resolved service enforces the deadline', async () => {
|
|
221
|
+
const layer = Layer.mergeAll(Kubernetes.providers(), boundedHelmChartProvider('1s', '1h'));
|
|
222
|
+
const api = fakeApiServer(UIDS, [], { [`PATCH ${HOST}${NS_PATH}`]: { hang: true } });
|
|
223
|
+
try {
|
|
224
|
+
const found = await Effect.runPromise(
|
|
225
|
+
Effect.gen(function* () {
|
|
226
|
+
const registered = yield* Provider.tryFindProviderRegistrationByType<HelmChart>(
|
|
227
|
+
HelmChart.Type,
|
|
228
|
+
);
|
|
229
|
+
if (Option.isNone(registered)) return yield* Effect.die('no registration');
|
|
230
|
+
const service = registered.value as unknown as Service;
|
|
231
|
+
const failure = yield* reconcile(service).pipe(Effect.flip);
|
|
232
|
+
return { failure, marked: isBoundedHelmChartProvider(service) };
|
|
233
|
+
}).pipe(
|
|
234
|
+
Effect.provide(Layer.provideMerge(layer, Layer.merge(services, stack))),
|
|
235
|
+
) as Effect.Effect<{ failure: unknown; marked: boolean }>,
|
|
236
|
+
);
|
|
237
|
+
expect(found.marked).toBe(true);
|
|
238
|
+
expect(found.failure).toBeInstanceOf(KubernetesReconcileTimeout);
|
|
239
|
+
} finally {
|
|
240
|
+
api.restore();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `boundedHelmChartProvider`: upstream's `Kubernetes.HelmChart` provider with a wall-clock deadline
|
|
3
|
+
* on `reconcile` and `read`. Wrapped, never re-implemented (alchemy-provider-standard: use what
|
|
4
|
+
* Alchemy ships).
|
|
5
|
+
*
|
|
6
|
+
* ⛔ WHY (measured 2026-10-09, alchemy 2.0.0-beta.81): `requestJson` in `Kubernetes/internal/client.ts`
|
|
7
|
+
* sets no timeout and no signal on any request, and retries transport errors only. A hung apiserver
|
|
8
|
+
* therefore blocks a HelmChart reconcile or read forever, holding the 1h platform token across a
|
|
9
|
+
* stuck deploy. The deadline turns that into a typed `KubernetesReconcileTimeout`.
|
|
10
|
+
* ★ A DIRECT `Provider(HelmChart)` layer, not a collection member: the engine checks the direct
|
|
11
|
+
* service before any collection (`Provider.ts` `tryFindProviderRegistrationByType`), so merging
|
|
12
|
+
* this beside `Kubernetes.providers()` makes the wrapper win with no change to upstream's layer.
|
|
13
|
+
* ⚠️ The interrupted fiber ABANDONS its socket (upstream takes no signal). The run then fails typed
|
|
14
|
+
* and the process exits once main completes, so a dangling socket cannot hold anything.
|
|
15
|
+
* `diff`, `delete`, `stables` and `aliases` come from upstream by spread, so state identity and
|
|
16
|
+
* replace semantics are exactly upstream's.
|
|
17
|
+
*/
|
|
18
|
+
import { HelmChart, HelmChartProvider } from 'alchemy/Kubernetes/HelmChart';
|
|
19
|
+
import * as Provider from 'alchemy/Provider';
|
|
20
|
+
import type * as Context from 'effect/Context';
|
|
21
|
+
import * as Data from 'effect/Data';
|
|
22
|
+
import * as Duration from 'effect/Duration';
|
|
23
|
+
import * as Effect from 'effect/Effect';
|
|
24
|
+
import * as Layer from 'effect/Layer';
|
|
25
|
+
import { scrubbed } from './ready-classify.ts';
|
|
26
|
+
import { parseGoDuration } from './ready.ts';
|
|
27
|
+
|
|
28
|
+
/** A render + discovery + ~60 server-side-apply PATCHes fit well inside this. */
|
|
29
|
+
export const HELM_RECONCILE_TIMEOUT = '5m0s';
|
|
30
|
+
/** `read` is one connect (identity GET): a minute is generous. */
|
|
31
|
+
export const HELM_READ_TIMEOUT = '1m0s';
|
|
32
|
+
|
|
33
|
+
/** The deadline passed with no answer from the apiserver. Names the row and seconds only. */
|
|
34
|
+
export class KubernetesReconcileTimeout extends Data.TaggedError('KubernetesReconcileTimeout')<{
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly operation: 'reconcile' | 'read';
|
|
37
|
+
readonly seconds: number;
|
|
38
|
+
}> {
|
|
39
|
+
override get message(): string {
|
|
40
|
+
return `Kubernetes.HelmChart ${this.id}: ${this.operation} did not finish within ${this.seconds}s (the apiserver stopped answering)`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bound = <A, E, R>(
|
|
45
|
+
effect: Effect.Effect<A, E, R>,
|
|
46
|
+
deadline: Duration.Duration,
|
|
47
|
+
id: string,
|
|
48
|
+
operation: 'reconcile' | 'read',
|
|
49
|
+
): Effect.Effect<A, E | KubernetesReconcileTimeout, R> =>
|
|
50
|
+
effect.pipe(
|
|
51
|
+
// ⛔ Upstream's `KubernetesApiError` quotes up to 1000 bytes of the apiserver body
|
|
52
|
+
// (`client.ts:39-41`): scrub it to a body-free error, as `Kubernetes.Ready` does.
|
|
53
|
+
Effect.mapError((error) => scrubbed(error) as E),
|
|
54
|
+
Effect.timeoutOrElse({
|
|
55
|
+
duration: deadline,
|
|
56
|
+
orElse: () =>
|
|
57
|
+
Effect.fail(
|
|
58
|
+
new KubernetesReconcileTimeout({ id, operation, seconds: Duration.toSeconds(deadline) }),
|
|
59
|
+
),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// `Provider(type)` is typed as an Effect; at runtime it is the service key the engine looks up.
|
|
64
|
+
const HelmChartKey = Provider.Provider<HelmChart>(HelmChart.Type) as unknown as Context.Service<
|
|
65
|
+
Provider.Provider<HelmChart>,
|
|
66
|
+
Provider.ProviderService<HelmChart>
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
const BOUNDED = Symbol.for('@homeflare/alchemy/kubernetes/boundedHelmChartProvider');
|
|
70
|
+
|
|
71
|
+
/** True for a service built by `boundedHelmChartProvider`: lets a stack test its real layer. */
|
|
72
|
+
export const isBoundedHelmChartProvider = (service: unknown): boolean =>
|
|
73
|
+
typeof service === 'object' &&
|
|
74
|
+
service !== null &&
|
|
75
|
+
(service as Record<symbol, unknown>)[BOUNDED] === true;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ⛔ A malformed duration dies at layer build (`Effect.orDie`), never becomes a silent unbounded
|
|
79
|
+
* call: a bad override cannot quietly drop the deadline.
|
|
80
|
+
*/
|
|
81
|
+
export const boundedHelmChartProvider = (
|
|
82
|
+
reconcileTimeout: string = HELM_RECONCILE_TIMEOUT,
|
|
83
|
+
readTimeout: string = HELM_READ_TIMEOUT,
|
|
84
|
+
) =>
|
|
85
|
+
Layer.effect(
|
|
86
|
+
HelmChartKey,
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const inner = yield* HelmChartKey;
|
|
89
|
+
const reconcileDeadline = yield* parseGoDuration(
|
|
90
|
+
'reconcileTimeout',
|
|
91
|
+
reconcileTimeout,
|
|
92
|
+
'Kubernetes.HelmChart',
|
|
93
|
+
);
|
|
94
|
+
const innerRead = inner.read?.bind(inner);
|
|
95
|
+
const readDeadline = yield* parseGoDuration(
|
|
96
|
+
'readTimeout',
|
|
97
|
+
readTimeout,
|
|
98
|
+
'Kubernetes.HelmChart',
|
|
99
|
+
);
|
|
100
|
+
const service = {
|
|
101
|
+
...inner,
|
|
102
|
+
reconcile: (input) =>
|
|
103
|
+
bound(inner.reconcile(input), reconcileDeadline, input.id, 'reconcile'),
|
|
104
|
+
...(innerRead === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { read: (input) => bound(innerRead(input), readDeadline, input.id, 'read') }),
|
|
107
|
+
} satisfies Provider.ProviderService<HelmChart>;
|
|
108
|
+
return Object.defineProperty(service, BOUNDED, { value: true });
|
|
109
|
+
}).pipe(Effect.orDie),
|
|
110
|
+
).pipe(Layer.provide(HelmChartProvider()));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes helpers for Alchemy that upstream does not ship.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A DELIBERATELY SMALL BARREL (the `talos/index.ts` rule): the resource, its provider, its
|
|
5
|
+
* props and the typed errors a stack can `catchTag`. The evaluators and the poll stay internal.
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
HELM_READ_TIMEOUT,
|
|
9
|
+
HELM_RECONCILE_TIMEOUT,
|
|
10
|
+
KubernetesReconcileTimeout,
|
|
11
|
+
boundedHelmChartProvider,
|
|
12
|
+
isBoundedHelmChartProvider,
|
|
13
|
+
} from './helm-chart-bounded.ts';
|
|
14
|
+
export {
|
|
15
|
+
KubernetesReady,
|
|
16
|
+
KubernetesReadyProvider,
|
|
17
|
+
type ReadyAttributes,
|
|
18
|
+
type ReadyProps,
|
|
19
|
+
} from './ready.ts';
|
|
20
|
+
export type { ReadyCheck } from './ready-checks.ts';
|
|
21
|
+
export {
|
|
22
|
+
KubernetesReadyBadAfter,
|
|
23
|
+
KubernetesReadyBadCheck,
|
|
24
|
+
KubernetesReadyBadDuration,
|
|
25
|
+
KubernetesReadyTimeout,
|
|
26
|
+
KubernetesRolloutFailed,
|
|
27
|
+
} from './ready-errors.ts';
|
|
28
|
+
export { KubernetesReadyApiError, KubernetesReadyGetTimeout } from './ready-poll.ts';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket rationing of the poll (upstream `readObject` takes no signal, so a timed-out GET abandons
|
|
3
|
+
* its socket) and the `diff` tolerance of transients. Fake apiserver in hung mode; time is TestClock's.
|
|
4
|
+
*/
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
import * as Cause from 'effect/Cause';
|
|
7
|
+
import * as Duration from 'effect/Duration';
|
|
8
|
+
import * as Exit from 'effect/Exit';
|
|
9
|
+
import { connectCluster } from 'alchemy/Kubernetes/internal/client';
|
|
10
|
+
import type { FakeObject } from '../talos/fake-apiserver.ts';
|
|
11
|
+
import { poll } from './ready-poll.ts';
|
|
12
|
+
import { diffReady } from './ready.ts';
|
|
13
|
+
import {
|
|
14
|
+
CRD_PATH,
|
|
15
|
+
DEP_PATH,
|
|
16
|
+
DS_PATH,
|
|
17
|
+
connection,
|
|
18
|
+
crdCheck,
|
|
19
|
+
depCheck,
|
|
20
|
+
driven,
|
|
21
|
+
dsCheck,
|
|
22
|
+
get,
|
|
23
|
+
readyCrd,
|
|
24
|
+
readyDep,
|
|
25
|
+
readyDs,
|
|
26
|
+
withCluster,
|
|
27
|
+
} from './ready.fixtures.ts';
|
|
28
|
+
|
|
29
|
+
const checks = [dsCheck, depCheck, crdCheck];
|
|
30
|
+
const allReady = (): Record<string, FakeObject> => ({
|
|
31
|
+
[get(CRD_PATH)]: { body: readyCrd },
|
|
32
|
+
[get(DEP_PATH)]: { body: readyDep },
|
|
33
|
+
[get(DS_PATH)]: { body: readyDs },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A live table that counts the GETs each key receives (the fake reads the table once per request). */
|
|
37
|
+
const counting = (objects: Record<string, FakeObject>) => {
|
|
38
|
+
const counts: Record<string, number> = {};
|
|
39
|
+
const table = new Proxy(objects, {
|
|
40
|
+
get: (target, prop) => {
|
|
41
|
+
counts[prop as string] = (counts[prop as string] ?? 0) + 1;
|
|
42
|
+
return target[prop as string];
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return { counts, table };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const prior = {
|
|
49
|
+
checks: [
|
|
50
|
+
'DaemonSet/kube-system/cilium',
|
|
51
|
+
'Deployment/kube-system/cilium-operator',
|
|
52
|
+
'CustomResourceDefinition/x.cilium.io',
|
|
53
|
+
].join(','),
|
|
54
|
+
connection,
|
|
55
|
+
ready: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
test('a hung apiserver costs few GETs over 10 minutes: back-off doubles the pause, capped at 30 s', async () => {
|
|
59
|
+
const { counts, table } = counting({ ...allReady(), [get(DS_PATH)]: { hang: true } });
|
|
60
|
+
const exit = await withCluster(
|
|
61
|
+
table,
|
|
62
|
+
driven(
|
|
63
|
+
poll(() => connectCluster(connection), checks, Duration.minutes(10), Duration.seconds(10)),
|
|
64
|
+
700,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
expect(Exit.isFailure(exit)).toBe(true);
|
|
68
|
+
// 5 s timeout + pause 20, 30, 30 ...: about 18 GETs. Without back-off it is about 40.
|
|
69
|
+
expect(counts[get(DS_PATH)]).toBeGreaterThan(10);
|
|
70
|
+
expect(counts[get(DS_PATH)]).toBeLessThan(25);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('once a GET times out, the rest of that pass issues no GET and is pending', async () => {
|
|
74
|
+
const { counts, table } = counting({ ...allReady(), [get(DS_PATH)]: { hang: true } });
|
|
75
|
+
const exit = await withCluster(
|
|
76
|
+
table,
|
|
77
|
+
driven(
|
|
78
|
+
poll(() => connectCluster(connection), checks, Duration.minutes(2), Duration.seconds(10)),
|
|
79
|
+
200,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
const error = Cause.squash((exit as Exit.Failure<unknown, unknown>).cause) as {
|
|
83
|
+
failing: string[];
|
|
84
|
+
};
|
|
85
|
+
expect(counts[get(DEP_PATH)] ?? 0).toBe(0);
|
|
86
|
+
expect(counts[get(CRD_PATH)] ?? 0).toBe(0);
|
|
87
|
+
expect(error.failing).toHaveLength(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
for (const [name, entry] of [
|
|
91
|
+
['a 503', { status: 503, body: { secret: 'x' } }],
|
|
92
|
+
['a 429', { status: 429 }],
|
|
93
|
+
['a hung GET', { hang: true }],
|
|
94
|
+
] as const) {
|
|
95
|
+
test(`diff returns update on ${name} instead of failing the plan`, async () => {
|
|
96
|
+
const objects = { ...allReady(), [get(DEP_PATH)]: entry };
|
|
97
|
+
const out = await withCluster(
|
|
98
|
+
objects,
|
|
99
|
+
driven(diffReady({ checks, connection } as never, prior), 30),
|
|
100
|
+
);
|
|
101
|
+
expect(Exit.isSuccess(out)).toBe(true);
|
|
102
|
+
expect((out as Exit.Success<unknown>).value).toEqual({ action: 'update' });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('diff returns update when the connect itself hits a 503; 401/403 still fail the plan', async () => {
|
|
107
|
+
const identity = get('/api/v1/namespaces/kube-system');
|
|
108
|
+
const ok = await withCluster(
|
|
109
|
+
{ ...allReady(), [identity]: { status: 503 } },
|
|
110
|
+
driven(diffReady({ checks, connection } as never, prior), 30),
|
|
111
|
+
);
|
|
112
|
+
expect((ok as Exit.Success<unknown>).value).toEqual({ action: 'update' });
|
|
113
|
+
for (const status of [401, 403]) {
|
|
114
|
+
const out = await withCluster(
|
|
115
|
+
{ ...allReady(), [get(DS_PATH)]: { status } },
|
|
116
|
+
driven(diffReady({ checks, connection } as never, prior), 30),
|
|
117
|
+
);
|
|
118
|
+
expect(Exit.isFailure(out)).toBe(true);
|
|
119
|
+
expect(
|
|
120
|
+
(Cause.squash((out as Exit.Failure<unknown, unknown>).cause) as { statusCode: number })
|
|
121
|
+
.statusCode,
|
|
122
|
+
).toBe(status);
|
|
123
|
+
}
|
|
124
|
+
});
|