@homeflare/alchemy 0.48.1 → 0.49.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/docs/cloudflared-tunnel.md +136 -0
- package/docs/credentials.md +1 -1
- package/docs/ownership.md +3 -2
- 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 +1 -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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudflaredTunnelProvider's handlers, called the way the engine calls them, over the fake
|
|
3
|
+
* Cloudflare in fake-tunnel.ts — and `engine`, which runs Alchemy's real plan/apply over the same
|
|
4
|
+
* fake and exposes the state rows it wrote. Shared by cloudflared-tunnel.test.ts,
|
|
5
|
+
* cloudflared-tunnel-edges.test.ts and cloudflared-tunnel-state.test.ts.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY, like fake-tunnel.ts: no provider imports this file and it is not on the barrel.
|
|
8
|
+
*/
|
|
9
|
+
import { apply } from 'alchemy/Apply';
|
|
10
|
+
import * as Plan from 'alchemy/Plan';
|
|
11
|
+
import { type CompiledStack, make as makeStack } from 'alchemy/Stack';
|
|
12
|
+
import { Stage } from 'alchemy/Stage';
|
|
13
|
+
import * as State from 'alchemy/State';
|
|
14
|
+
import * as Cause from 'effect/Cause';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as Exit from 'effect/Exit';
|
|
17
|
+
import * as Layer from 'effect/Layer';
|
|
18
|
+
import * as FetchHttpClient from 'effect/http/FetchHttpClient';
|
|
19
|
+
import { CloudflaredTunnel, CloudflaredTunnelProvider } from './cloudflared-tunnel.ts';
|
|
20
|
+
import type {
|
|
21
|
+
CloudflaredTunnelAttributes,
|
|
22
|
+
CloudflaredTunnelProps,
|
|
23
|
+
} from './cloudflared-tunnel-form.ts';
|
|
24
|
+
import { fakeProviderLayer } from './fake-mesh.ts';
|
|
25
|
+
import type { FakeTunnels } from './fake-tunnel.ts';
|
|
26
|
+
|
|
27
|
+
export const ids = { fqn: 'stack/admin', id: 'admin', instanceId: 'i-1' };
|
|
28
|
+
export const extra = { bindings: [] as never, session: undefined as never };
|
|
29
|
+
|
|
30
|
+
export type P = Effect.Success<typeof CloudflaredTunnel.Provider>;
|
|
31
|
+
|
|
32
|
+
export const handler = <F>(name: string, fn: F | undefined): F => {
|
|
33
|
+
if (fn === undefined) throw new Error(`provider has no ${name} handler`);
|
|
34
|
+
return fn;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Build the provider over the fake (as `accountId`'s environment) and hand it to `use`. */
|
|
38
|
+
export const run = <A, E>(
|
|
39
|
+
fake: FakeTunnels,
|
|
40
|
+
use: (p: P) => Effect.Effect<A, E>,
|
|
41
|
+
accountId?: string,
|
|
42
|
+
) =>
|
|
43
|
+
Effect.runPromise(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
return yield* use(yield* CloudflaredTunnel.Provider);
|
|
46
|
+
}).pipe(
|
|
47
|
+
Effect.provide(
|
|
48
|
+
CloudflaredTunnelProvider().pipe(Layer.provideMerge(fakeProviderLayer(fake, accountId))),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const reconcile = (
|
|
54
|
+
p: P,
|
|
55
|
+
news: CloudflaredTunnelProps,
|
|
56
|
+
output?: CloudflaredTunnelAttributes,
|
|
57
|
+
) => p.reconcile({ ...ids, ...extra, news, olds: undefined, output });
|
|
58
|
+
|
|
59
|
+
export const read = (p: P, olds: CloudflaredTunnelProps, output?: CloudflaredTunnelAttributes) =>
|
|
60
|
+
handler('read', p.read)({ ...ids, olds, output });
|
|
61
|
+
|
|
62
|
+
export const diff = (
|
|
63
|
+
p: P,
|
|
64
|
+
news: CloudflaredTunnelProps,
|
|
65
|
+
olds: CloudflaredTunnelProps,
|
|
66
|
+
output: CloudflaredTunnelAttributes,
|
|
67
|
+
) =>
|
|
68
|
+
handler(
|
|
69
|
+
'diff',
|
|
70
|
+
p.diff,
|
|
71
|
+
)({ ...ids, news, olds, newBindings: [] as never, oldBindings: [] as never, output });
|
|
72
|
+
|
|
73
|
+
/** A stored attribute set for a tunnel that may or may not exist in the fake. */
|
|
74
|
+
export const stored = (
|
|
75
|
+
accountId: string,
|
|
76
|
+
over: Partial<CloudflaredTunnelAttributes> = {},
|
|
77
|
+
): CloudflaredTunnelAttributes => ({
|
|
78
|
+
id: 'x',
|
|
79
|
+
accountId,
|
|
80
|
+
name: 'k8s-admin',
|
|
81
|
+
...over,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Requests the fake saw for any `/token` path. The provider must never make one. */
|
|
85
|
+
export const tokenReads = (fake: FakeTunnels) =>
|
|
86
|
+
fake.seen.filter((s) => s.path.includes('/token')).length;
|
|
87
|
+
|
|
88
|
+
/** The writes the fake saw, in order: `['POST', 'DELETE', …]`. */
|
|
89
|
+
export const writes = (fake: FakeTunnels) =>
|
|
90
|
+
fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method);
|
|
91
|
+
|
|
92
|
+
type Rows = Record<string, Record<string, Record<string, State.ResourceState>>>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* ★ THE REAL ENGINE, NOT A MODEL OF IT: Alchemy's own `Plan.make` + `apply` over an in-memory
|
|
96
|
+
* state store (the same construction as mesh-node-harness.ts, which says why
|
|
97
|
+
* `FetchHttpClient.Fetch` is provided around the whole deploy). `rows` is the store itself, so a
|
|
98
|
+
* test can serialize exactly what Alchemy would persist.
|
|
99
|
+
*/
|
|
100
|
+
export const engine = (fake: FakeTunnels) => {
|
|
101
|
+
const rows: Rows = {};
|
|
102
|
+
const state = Layer.succeed(State.State, State.InMemoryService(rows));
|
|
103
|
+
const providers = CloudflaredTunnelProvider().pipe(Layer.provide(fakeProviderLayer(fake)));
|
|
104
|
+
const deploy = <A, E, R>(declare: Effect.Effect<A, E, R>) =>
|
|
105
|
+
Effect.runPromiseExit(
|
|
106
|
+
declare.pipe(
|
|
107
|
+
makeStack({ name: 'tunnel', providers, state }) as never,
|
|
108
|
+
Effect.flatMap((compiled: CompiledStack) =>
|
|
109
|
+
Plan.make(compiled).pipe(Effect.flatMap(apply), Effect.provide(compiled.services)),
|
|
110
|
+
),
|
|
111
|
+
Effect.provide(Layer.succeed(Stage, 'test')),
|
|
112
|
+
Effect.scoped,
|
|
113
|
+
Effect.provideService(FetchHttpClient.Fetch, fake.fetch),
|
|
114
|
+
) as Effect.Effect<unknown, unknown>,
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
deploy,
|
|
118
|
+
rows,
|
|
119
|
+
/** The stored row's status (`created`, `replacing`, …), or `undefined` when there is none. */
|
|
120
|
+
status: (fqn: string) => rows['tunnel']?.['test']?.[fqn]?.status,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The failure sentence of a deploy, or `''` when it succeeded. */
|
|
125
|
+
export const failureOf = (exit: Exit.Exit<unknown, unknown>) =>
|
|
126
|
+
Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : '';
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read / reconcile / delete for `Cloudflare.CloudflaredTunnel`, as plain Effects over the SDK calls
|
|
3
|
+
* in cloudflared-tunnel-api.ts. cloudflared-tunnel.ts only wires them into Alchemy.
|
|
4
|
+
*/
|
|
5
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import {
|
|
8
|
+
CloudflaredTunnelError,
|
|
9
|
+
type ObservedTunnel,
|
|
10
|
+
createTunnel,
|
|
11
|
+
deleteTunnel,
|
|
12
|
+
findTunnelByName,
|
|
13
|
+
getTunnel,
|
|
14
|
+
renameTunnel,
|
|
15
|
+
} from './cloudflared-tunnel-api.ts';
|
|
16
|
+
import {
|
|
17
|
+
type CloudflaredTunnelAttributes,
|
|
18
|
+
type CloudflaredTunnelProps,
|
|
19
|
+
validateCloudflaredTunnel,
|
|
20
|
+
} from './cloudflared-tunnel-form.ts';
|
|
21
|
+
|
|
22
|
+
const attributesOf = (tunnel: ObservedTunnel, accountId: string): CloudflaredTunnelAttributes => ({
|
|
23
|
+
id: tunnel.id,
|
|
24
|
+
accountId,
|
|
25
|
+
name: tunnel.name,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Owned: refresh by the stored id. Cold (no stored row): find the live tunnel by exact name and
|
|
30
|
+
* hand it back `Unowned`, so Alchemy refuses to take it over until the stack says `adopt(true)` —
|
|
31
|
+
* a `cfd_tunnel` carries no ownership marker to prove it was ours. Adoption is idempotent: the
|
|
32
|
+
* adopted row holds the id, so every later `read` takes the owned path and writes nothing.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ A STORED ID THAT NO LONGER RESOLVES IS "MISSING", NEVER A NAME PROBE. Drift (alchemy beta.81
|
|
35
|
+
* src/Drift.ts) passes the row's output and treats whatever `read` returns as already ours
|
|
36
|
+
* (`stripUnowned`), then repairs by reconciling onto it. A by-name fallback there would write a
|
|
37
|
+
* same-named tunnel somebody else made into this row, bypassing `adopt(true)`; a later rename
|
|
38
|
+
* would PATCH it and `RemovalPolicy.destroy` would DELETE it. Returning `undefined` makes Drift
|
|
39
|
+
* report "missing" and recreate through `reconcile`, where `createFresh` names the holder.
|
|
40
|
+
*/
|
|
41
|
+
export const readCloudflaredTunnel = (
|
|
42
|
+
accountId: string,
|
|
43
|
+
olds: CloudflaredTunnelProps | undefined,
|
|
44
|
+
output: CloudflaredTunnelAttributes | undefined,
|
|
45
|
+
) =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const account = output?.accountId ?? accountId;
|
|
48
|
+
if (output !== undefined) {
|
|
49
|
+
const tunnel = yield* getTunnel(account, output.id);
|
|
50
|
+
return tunnel === undefined ? undefined : attributesOf(tunnel, account);
|
|
51
|
+
}
|
|
52
|
+
const name = olds?.name;
|
|
53
|
+
if (name === undefined) return undefined;
|
|
54
|
+
const match = yield* findTunnelByName(account, name);
|
|
55
|
+
return match === undefined ? undefined : Unowned(attributesOf(match, account));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⛔ NEVER CONVERGE ON A TUNNEL THIS RESOURCE DID NOT FIND BY ITS OWN ID. Alchemy's own Tunnel
|
|
60
|
+
* answers a duplicate-name failure by re-reading the holder and returning it (when its `adopt`
|
|
61
|
+
* prop is set). Here adoption belongs to `read` and `adopt(true)` alone, where it is visible in
|
|
62
|
+
* the plan. The holder is named, never deleted: it may be a live tunnel that serves traffic.
|
|
63
|
+
*/
|
|
64
|
+
const nameHeld = (name: string, holder: string) =>
|
|
65
|
+
new CloudflaredTunnelError({
|
|
66
|
+
message:
|
|
67
|
+
`A tunnel named "${name}" already exists (${holder}) and is not the tunnel this resource ` +
|
|
68
|
+
'tracks, so it is left alone: names are unique per account. If it is meant to be this ' +
|
|
69
|
+
'one, deploy under adopt(true). Otherwise choose another name; do not delete it.',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 🔴 A 1013 AFTER A CLEAN LOOKUP IS USUALLY THIS DEPLOY'S OWN TUNNEL. distilled retries a POST on a
|
|
74
|
+
* transport error or a 5xx, so a create that succeeded but lost its response is sent again and
|
|
75
|
+
* answered 1013 by the tunnel the first attempt made. The lookup is repeated so the sentence
|
|
76
|
+
* names that tunnel; it is still not adopted here, because a concurrent creator looks the same.
|
|
77
|
+
*/
|
|
78
|
+
const raced = (accountId: string, name: string) =>
|
|
79
|
+
findTunnelByName(accountId, name).pipe(
|
|
80
|
+
Effect.flatMap((holder) =>
|
|
81
|
+
Effect.fail(
|
|
82
|
+
holder === undefined
|
|
83
|
+
? nameHeld(
|
|
84
|
+
name,
|
|
85
|
+
'not a live cloudflared tunnel: another tunnel type, or one just deleted',
|
|
86
|
+
)
|
|
87
|
+
: new CloudflaredTunnelError({
|
|
88
|
+
message:
|
|
89
|
+
`Tunnel "${name}" (${holder.id}) appeared between this deploy's lookup and its ` +
|
|
90
|
+
'create. The likeliest cause is this deploy itself: the SDK retries a create ' +
|
|
91
|
+
'whose response was lost. If so, deploy again under adopt(true). Otherwise ' +
|
|
92
|
+
'another creator raced this one.',
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const createFresh = (accountId: string, name: string) =>
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const existing = yield* findTunnelByName(accountId, name);
|
|
101
|
+
if (existing !== undefined) return yield* Effect.fail(nameHeld(name, existing.id));
|
|
102
|
+
const tunnel = yield* createTunnel(accountId, name).pipe(
|
|
103
|
+
Effect.catchTag('DuplicateTunnelName', () => raced(accountId, name)),
|
|
104
|
+
);
|
|
105
|
+
return attributesOf(tunnel, accountId);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ⛔ `local` IS REFUSED. A tunnel's `config_src` is create-only, and adoption by name could land on
|
|
110
|
+
* a locally configured tunnel, whose ingress is a YAML file on its origin: a consumer's remote
|
|
111
|
+
* rules would be accepted by the API and never applied. The tunnel is left untouched.
|
|
112
|
+
*/
|
|
113
|
+
const requireRemote = (tunnel: ObservedTunnel) =>
|
|
114
|
+
tunnel.configSrc === 'local'
|
|
115
|
+
? Effect.fail(
|
|
116
|
+
new CloudflaredTunnelError({
|
|
117
|
+
message: `Tunnel "${tunnel.name}" (${tunnel.id}) is locally configured (config_src local), which cannot be changed after creation. CloudflaredTunnel manages remotely configured tunnels only; create a new tunnel under another name.`,
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
: Effect.void;
|
|
121
|
+
|
|
122
|
+
export const reconcileCloudflaredTunnel = (
|
|
123
|
+
accountId: string,
|
|
124
|
+
news: CloudflaredTunnelProps,
|
|
125
|
+
output: CloudflaredTunnelAttributes | undefined,
|
|
126
|
+
) =>
|
|
127
|
+
Effect.gen(function* () {
|
|
128
|
+
const invalid = validateCloudflaredTunnel(news);
|
|
129
|
+
if (invalid !== undefined) return yield* Effect.fail(invalid);
|
|
130
|
+
const owned = output !== undefined && output.accountId === accountId ? output : undefined;
|
|
131
|
+
const live = owned === undefined ? undefined : yield* getTunnel(accountId, owned.id);
|
|
132
|
+
// ★ Gone out of band (or a replace's new generation): create, under the same refusals.
|
|
133
|
+
if (owned === undefined || live === undefined) return yield* createFresh(accountId, news.name);
|
|
134
|
+
yield* requireRemote(live);
|
|
135
|
+
if (live.name === news.name) return attributesOf(live, accountId);
|
|
136
|
+
const renamed = yield* renameTunnel(accountId, live.id, news.name).pipe(
|
|
137
|
+
Effect.catchTag('DuplicateTunnelName', () =>
|
|
138
|
+
Effect.fail(nameHeld(news.name, 'held by another tunnel')),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
return attributesOf(renamed, accountId);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** By the stored id and account only (cloudflared-tunnel-api.ts, `deleteTunnel`). */
|
|
145
|
+
export const deleteCloudflaredTunnel = (output: CloudflaredTunnelAttributes) =>
|
|
146
|
+
deleteTunnel(output.accountId, output.id);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Alchemy WRITES for a CloudflaredTunnel, through the real engine (`engine` in
|
|
3
|
+
* cloudflared-tunnel-harness.ts) over the fake Cloudflare: the serialization proof the resource
|
|
4
|
+
* exists for, plus the removal policy and adoption, which live in the engine, not the provider.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THE PROOF: the rows in the state store, encoded by Alchemy's own `encodeState` (the function
|
|
7
|
+
* LocalState, HttpStateStore and PostgresState all call before writing), contain no `__redacted__`
|
|
8
|
+
* marker, no `token` key and no token value — and the fake handed out a token on the create
|
|
9
|
+
* response, every wire object and its `/token` endpoint. A negative control shows the same
|
|
10
|
+
* encoder DOES write a `Redacted` token as plaintext, so the absence above means something.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
14
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
15
|
+
import * as State from 'alchemy/State';
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import * as Redacted from 'effect/Redacted';
|
|
18
|
+
import { CloudflaredTunnel } from './cloudflared-tunnel.ts';
|
|
19
|
+
import { engine, failureOf, tokenReads, writes } from './cloudflared-tunnel-harness.ts';
|
|
20
|
+
import { fakeTunnels } from './fake-tunnel.ts';
|
|
21
|
+
|
|
22
|
+
const ADMIN = 'admin';
|
|
23
|
+
const admin = (name = 'k8s-admin') => CloudflaredTunnel(ADMIN, { name });
|
|
24
|
+
|
|
25
|
+
const persisted = (rows: object) => JSON.stringify(State.encodeState(rows));
|
|
26
|
+
|
|
27
|
+
describe('CloudflaredTunnel state serialization', () => {
|
|
28
|
+
test('the persisted state holds no token, no __redacted__ marker and no secret field', async () => {
|
|
29
|
+
const fake = fakeTunnels();
|
|
30
|
+
const stack = engine(fake);
|
|
31
|
+
expect(failureOf(await stack.deploy(admin()))).toBe('');
|
|
32
|
+
// A second deploy re-reads and re-reconciles: the paths Alchemy's own Tunnel reads the token on.
|
|
33
|
+
expect(failureOf(await stack.deploy(admin('k8s-admin-2')))).toBe('');
|
|
34
|
+
const [tunnel] = fake.live();
|
|
35
|
+
const json = persisted(stack.rows);
|
|
36
|
+
expect(json).toContain(tunnel?.id ?? 'no tunnel');
|
|
37
|
+
expect(json).not.toContain(State.REDACTED_MARKER);
|
|
38
|
+
expect(json).not.toContain(tunnel?.token ?? 'no tunnel');
|
|
39
|
+
expect(json).not.toContain('fake-tunnel-token');
|
|
40
|
+
expect(json.toLowerCase()).not.toContain('token');
|
|
41
|
+
expect(json.toLowerCase()).not.toContain('secret');
|
|
42
|
+
expect(tokenReads(fake)).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the attributes in the row are exactly id, accountId and name', async () => {
|
|
46
|
+
const fake = fakeTunnels();
|
|
47
|
+
const stack = engine(fake);
|
|
48
|
+
await stack.deploy(admin());
|
|
49
|
+
const row = stack.rows['tunnel']?.['test']?.[ADMIN] as { attr?: object } | undefined;
|
|
50
|
+
expect(Object.keys(row?.attr ?? {}).sort()).toEqual(['accountId', 'id', 'name']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('negative control: the same encoder writes a Redacted token as a plaintext marker', () => {
|
|
54
|
+
const json = persisted({ attr: { token: Redacted.make('t0ken') } });
|
|
55
|
+
expect(json).toContain(State.REDACTED_MARKER);
|
|
56
|
+
expect(json).toContain('t0ken');
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('CloudflaredTunnel through the real engine', () => {
|
|
61
|
+
test('retain is the default: dropping the declaration leaves the tunnel and sends no DELETE', async () => {
|
|
62
|
+
const fake = fakeTunnels();
|
|
63
|
+
const stack = engine(fake);
|
|
64
|
+
await stack.deploy(admin());
|
|
65
|
+
expect(failureOf(await stack.deploy(Effect.void))).toBe('');
|
|
66
|
+
expect(stack.status(ADMIN)).toBeUndefined();
|
|
67
|
+
expect(fake.live()).toHaveLength(1);
|
|
68
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('RemovalPolicy.destroy() still reaches the implemented delete', async () => {
|
|
72
|
+
const fake = fakeTunnels();
|
|
73
|
+
const stack = engine(fake);
|
|
74
|
+
await stack.deploy(admin().pipe(RemovalPolicy.destroy()));
|
|
75
|
+
expect(failureOf(await stack.deploy(Effect.void))).toBe('');
|
|
76
|
+
expect(fake.live()).toHaveLength(0);
|
|
77
|
+
expect(writes(fake)).toEqual(['POST', 'DELETE']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a rename deploys as a PATCH on the same tunnel', async () => {
|
|
81
|
+
const fake = fakeTunnels();
|
|
82
|
+
const stack = engine(fake);
|
|
83
|
+
await stack.deploy(admin());
|
|
84
|
+
const [before] = fake.live();
|
|
85
|
+
expect(failureOf(await stack.deploy(admin('k8s-admin-2')))).toBe('');
|
|
86
|
+
expect(fake.live().map((t) => [t.id, t.name])).toEqual([[before?.id ?? '', 'k8s-admin-2']]);
|
|
87
|
+
expect(writes(fake)).toEqual(['POST', 'PATCH']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an existing tunnel is refused without adopt(true), adopted with it, and a repeat writes nothing', async () => {
|
|
91
|
+
const fake = fakeTunnels();
|
|
92
|
+
const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
|
|
93
|
+
const stack = engine(fake);
|
|
94
|
+
expect(failureOf(await stack.deploy(admin()))).not.toBe('');
|
|
95
|
+
expect(writes(fake)).toEqual([]);
|
|
96
|
+
expect(failureOf(await stack.deploy(admin().pipe(adopt(true))))).toBe('');
|
|
97
|
+
expect(failureOf(await stack.deploy(admin().pipe(adopt(true))))).toBe('');
|
|
98
|
+
expect(writes(fake)).toEqual([]);
|
|
99
|
+
expect(fake.live().map((t) => t.id)).toEqual([tunnel.id]);
|
|
100
|
+
expect(tokenReads(fake)).toBe(0);
|
|
101
|
+
const row = stack.rows['tunnel']?.['test']?.[ADMIN] as { attr?: { id?: string } } | undefined;
|
|
102
|
+
expect(row?.attr?.id).toBe(tunnel.id);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudflaredTunnelProvider against a fake Cloudflare (fake-tunnel.ts), its handlers called the way
|
|
3
|
+
* the engine calls them.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ASSERTION THAT MATTERS MOST: no lifecycle handler ever requests `/token`, and nothing the
|
|
6
|
+
* provider returns contains the tunnel token — even though the fake puts one on every wire object.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
+
import type { StackServices } from 'alchemy/Stack';
|
|
11
|
+
import * as Effect from 'effect/Effect';
|
|
12
|
+
import type * as Layer from 'effect/Layer';
|
|
13
|
+
import {
|
|
14
|
+
diff,
|
|
15
|
+
extra,
|
|
16
|
+
ids,
|
|
17
|
+
read,
|
|
18
|
+
reconcile,
|
|
19
|
+
run,
|
|
20
|
+
stored,
|
|
21
|
+
tokenReads,
|
|
22
|
+
writes,
|
|
23
|
+
} from './cloudflared-tunnel-harness.ts';
|
|
24
|
+
import { FAKE_ACCOUNT, FAKE_API_TOKEN } from './fake-mesh.ts';
|
|
25
|
+
import { fakeTunnels } from './fake-tunnel.ts';
|
|
26
|
+
import { providers } from './providers.ts';
|
|
27
|
+
|
|
28
|
+
// ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`.
|
|
29
|
+
type Built = Layer.Success<ReturnType<typeof providers>>;
|
|
30
|
+
const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
|
|
31
|
+
void _stackShaped;
|
|
32
|
+
|
|
33
|
+
const props = { name: 'k8s-admin' };
|
|
34
|
+
|
|
35
|
+
describe('CloudflaredTunnelProvider', () => {
|
|
36
|
+
test('create sends config_src cloudflare and no secret, and the attributes carry no token', async () => {
|
|
37
|
+
const fake = fakeTunnels();
|
|
38
|
+
const out = await run(fake, (p) => reconcile(p, props));
|
|
39
|
+
const post = fake.seen.find((s) => s.method === 'POST');
|
|
40
|
+
expect(post?.body).toEqual({ name: 'k8s-admin', config_src: 'cloudflare' });
|
|
41
|
+
expect(out).toMatchObject({ name: 'k8s-admin', accountId: FAKE_ACCOUNT });
|
|
42
|
+
expect(Object.keys(out).sort()).toEqual(['accountId', 'id', 'name']);
|
|
43
|
+
expect(JSON.stringify(out)).not.toContain('fake-tunnel-token');
|
|
44
|
+
expect(tokenReads(fake)).toBe(0);
|
|
45
|
+
expect(new Set(fake.auth)).toEqual(new Set([`Bearer ${FAKE_API_TOKEN}`]));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('read with state refreshes by id; without state it adopts by exact name, Unowned', async () => {
|
|
49
|
+
const fake = fakeTunnels();
|
|
50
|
+
fake.seed({ name: 'k8s-admin-old' });
|
|
51
|
+
fake.seed({ name: 'k8s-admin-older' });
|
|
52
|
+
const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
|
|
53
|
+
const [owned, probe] = await run(fake, (p) =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const output = stored(FAKE_ACCOUNT, { id: tunnel.id });
|
|
56
|
+
return [yield* read(p, props, output), yield* read(p, props)] as const;
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
expect(Unowned.is(owned)).toBe(false);
|
|
60
|
+
expect(owned).toMatchObject({ id: tunnel.id, name: 'k8s-admin' });
|
|
61
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
62
|
+
expect(probe).toMatchObject({ id: tunnel.id, name: 'k8s-admin' });
|
|
63
|
+
expect(tokenReads(fake)).toBe(0);
|
|
64
|
+
expect(writes(fake)).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('adopting twice is idempotent: the second reconcile over the adopted row writes nothing', async () => {
|
|
68
|
+
const fake = fakeTunnels();
|
|
69
|
+
const tunnel = fake.seed({ name: 'k8s-admin', status: 'healthy' });
|
|
70
|
+
const [first, second] = await run(fake, (p) =>
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const adopted = yield* read(p, props);
|
|
73
|
+
expect(Unowned.is(adopted)).toBe(true);
|
|
74
|
+
const first = yield* reconcile(p, props, adopted);
|
|
75
|
+
return [first, yield* reconcile(p, props, first)] as const;
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
expect(first).toEqual(second);
|
|
79
|
+
expect(first.id).toBe(tunnel.id);
|
|
80
|
+
expect(writes(fake)).toEqual([]);
|
|
81
|
+
expect(tokenReads(fake)).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a rename is a PATCH on the same id', async () => {
|
|
85
|
+
const fake = fakeTunnels();
|
|
86
|
+
const [first, second] = await run(fake, (p) =>
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const first = yield* reconcile(p, props);
|
|
89
|
+
return [first, yield* reconcile(p, { name: 'k8s-admin-2' }, first)] as const;
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
expect(second.id).toBe(first.id);
|
|
93
|
+
expect(second.name).toBe('k8s-admin-2');
|
|
94
|
+
expect(writes(fake)).toEqual(['POST', 'PATCH']);
|
|
95
|
+
expect(fake.seen.find((s) => s.method === 'PATCH')?.body).toEqual({ name: 'k8s-admin-2' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('an unchanged tunnel costs no write', async () => {
|
|
99
|
+
const fake = fakeTunnels();
|
|
100
|
+
await run(fake, (p) => Effect.flatMap(reconcile(p, props), (out) => reconcile(p, props, out)));
|
|
101
|
+
expect(writes(fake)).toEqual(['POST']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a tunnel deleted out of band is created again', async () => {
|
|
105
|
+
const fake = fakeTunnels();
|
|
106
|
+
const [first, second] = await run(fake, (p) =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const first = yield* reconcile(p, props);
|
|
109
|
+
const tunnel = fake.tunnels.get(first.id);
|
|
110
|
+
if (tunnel !== undefined) tunnel.deleted_at = '2026-10-09T02:00:00Z';
|
|
111
|
+
return [first, yield* reconcile(p, props, first)] as const;
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
expect(second.id).not.toBe(first.id);
|
|
115
|
+
expect(fake.live()).toHaveLength(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('delete removes the tunnel by id, and deleting it again sends nothing', async () => {
|
|
119
|
+
const fake = fakeTunnels();
|
|
120
|
+
await run(fake, (p) =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const output = yield* reconcile(p, props);
|
|
123
|
+
yield* p.delete({ ...ids, ...extra, olds: props, output });
|
|
124
|
+
yield* p.delete({ ...ids, ...extra, olds: props, output });
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
expect(fake.live()).toHaveLength(0);
|
|
128
|
+
expect(writes(fake)).toEqual(['POST', 'DELETE']);
|
|
129
|
+
expect(tokenReads(fake)).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('diff reads the account from the provider environment', async () => {
|
|
133
|
+
const fake = fakeTunnels();
|
|
134
|
+
const plan = (p: Parameters<typeof diff>[0]) => diff(p, props, props, stored(FAKE_ACCOUNT));
|
|
135
|
+
expect(await run(fake, plan)).toBeUndefined();
|
|
136
|
+
expect(await run(fake, plan, '00000000000000000000000000000002')).toEqual({
|
|
137
|
+
action: 'replace',
|
|
138
|
+
});
|
|
139
|
+
const renamed = await run(fake, (p) =>
|
|
140
|
+
diff(p, { name: 'k8s-admin-2' }, props, stored(FAKE_ACCOUNT)),
|
|
141
|
+
);
|
|
142
|
+
expect(renamed).toEqual({ action: 'update' });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('an invalid declaration fails the PLAN', async () => {
|
|
146
|
+
const fake = fakeTunnels();
|
|
147
|
+
const failure = await run(fake, (p) =>
|
|
148
|
+
Effect.flip(diff(p, { name: 'k8s-admin ' }, props, stored(FAKE_ACCOUNT))),
|
|
149
|
+
);
|
|
150
|
+
expect(String(failure)).toContain('whitespace');
|
|
151
|
+
expect(fake.seen).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('list is empty and nuke skips it: Alchemy Tunnel already enumerates these', async () => {
|
|
155
|
+
const fake = fakeTunnels();
|
|
156
|
+
fake.seed({ name: 'k8s-admin' });
|
|
157
|
+
const [listed, nuke] = await run(fake, (p) =>
|
|
158
|
+
Effect.map(p.list(), (rows) => [rows, p.nuke] as const),
|
|
159
|
+
);
|
|
160
|
+
expect(listed).toEqual([]);
|
|
161
|
+
expect(nuke).toEqual({ skip: true });
|
|
162
|
+
expect(fake.seen).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cloudflare.CloudflaredTunnel` — a Cloudflare Tunnel (the cloudflared kind, `cfd_tunnel`),
|
|
3
|
+
* declared without its connector token ever touching Alchemy state. Usage: docs/cloudflared-tunnel.md.
|
|
4
|
+
*
|
|
5
|
+
* 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.Tunnel`. Alchemy beta.81's resource stores the
|
|
6
|
+
* token as a `Redacted` attribute, and the state encoder writes it out in plaintext. See
|
|
7
|
+
* cloudflared-tunnel-form.ts, which also says what this resource leaves to other declarations.
|
|
8
|
+
*
|
|
9
|
+
* ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `CloudflaredTunnel` below.
|
|
10
|
+
*/
|
|
11
|
+
import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
|
|
12
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
13
|
+
import { isResolved } from 'alchemy/Diff';
|
|
14
|
+
import * as Provider from 'alchemy/Provider';
|
|
15
|
+
import { Resource, type ResourceClass } from 'alchemy/Resource';
|
|
16
|
+
import * as Context from 'effect/Context';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import * as HttpClient from 'effect/http/HttpClient';
|
|
19
|
+
import {
|
|
20
|
+
type CloudflaredTunnelAttributes,
|
|
21
|
+
type CloudflaredTunnelProps,
|
|
22
|
+
diffCloudflaredTunnel,
|
|
23
|
+
validateCloudflaredTunnel,
|
|
24
|
+
} from './cloudflared-tunnel-form.ts';
|
|
25
|
+
import {
|
|
26
|
+
deleteCloudflaredTunnel,
|
|
27
|
+
readCloudflaredTunnel,
|
|
28
|
+
reconcileCloudflaredTunnel,
|
|
29
|
+
} from './cloudflared-tunnel-lifecycle.ts';
|
|
30
|
+
import type { Providers } from './providers.ts';
|
|
31
|
+
|
|
32
|
+
export interface CloudflaredTunnel extends Resource<
|
|
33
|
+
'Cloudflare.CloudflaredTunnel',
|
|
34
|
+
CloudflaredTunnelProps,
|
|
35
|
+
CloudflaredTunnelAttributes,
|
|
36
|
+
never,
|
|
37
|
+
Providers
|
|
38
|
+
> {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
|
|
42
|
+
* (MeshNode, the `Bao.*` families, `R2BucketLock`; the reasoning is written once in
|
|
43
|
+
* proxmox/resource.ts). Deleting a tunnel drops its remote configuration (ingress rules) and
|
|
44
|
+
* orphans every DNS record that targets `<id>.cfargotunnel.com`, and a new tunnel has a new id
|
|
45
|
+
* and a new token. So dropping the declaration, or `alchemy destroy`, leaves the tunnel live.
|
|
46
|
+
* ⚠️ IT ALSO SKIPS THE OLD TUNNEL'S DELETE IN AN ACCOUNT-CHANGE REPLACE (create-first), which
|
|
47
|
+
* leaves the old tunnel live and unmanaged (Apply.ts, `deleteOldGenerations`, beta.81).
|
|
48
|
+
* ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
|
|
49
|
+
* the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
50
|
+
*/
|
|
51
|
+
export const CloudflaredTunnel: ResourceClass<CloudflaredTunnel> = Resource<CloudflaredTunnel>(
|
|
52
|
+
'Cloudflare.CloudflaredTunnel',
|
|
53
|
+
{ defaultRemovalPolicy: 'retain' },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
|
|
57
|
+
export type CloudflaredTunnelServices =
|
|
58
|
+
| Cloudflare.CloudflareEnvironment
|
|
59
|
+
| Credentials
|
|
60
|
+
| HttpClient.HttpClient;
|
|
61
|
+
|
|
62
|
+
/** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
|
|
63
|
+
const accountId = Effect.gen(function* () {
|
|
64
|
+
const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
|
|
65
|
+
return resolved.accountId;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, exactly as `MeshNodeProvider` does
|
|
70
|
+
* (mesh-node.ts says why): correct whether or not the stack also merges `Cloudflare.providers()`.
|
|
71
|
+
*/
|
|
72
|
+
export const CloudflaredTunnelProvider = () =>
|
|
73
|
+
Provider.effect(
|
|
74
|
+
CloudflaredTunnel,
|
|
75
|
+
Effect.gen(function* () {
|
|
76
|
+
const services = Context.make(
|
|
77
|
+
Cloudflare.CloudflareEnvironment,
|
|
78
|
+
yield* Cloudflare.CloudflareEnvironment,
|
|
79
|
+
).pipe(
|
|
80
|
+
Context.add(Credentials, yield* Credentials),
|
|
81
|
+
Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
|
82
|
+
);
|
|
83
|
+
const run = <A, E>(effect: Effect.Effect<A, E, CloudflaredTunnelServices>) =>
|
|
84
|
+
Effect.provideContext(effect, services);
|
|
85
|
+
|
|
86
|
+
return CloudflaredTunnel.Provider.of({
|
|
87
|
+
stables: ['id', 'accountId'],
|
|
88
|
+
/**
|
|
89
|
+
* ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.Tunnel` already lists every
|
|
90
|
+
* `cfd_tunnel` in the account for `alchemy unsafe nuke` (and fetches each token to do
|
|
91
|
+
* it); a second listing here would make nuke delete each tunnel twice. `read` does not
|
|
92
|
+
* depend on this.
|
|
93
|
+
*/
|
|
94
|
+
list: () => Effect.succeed([]),
|
|
95
|
+
nuke: { skip: true },
|
|
96
|
+
/** ⛔ Validate at plan time too, so a bad declaration never reaches a replace. */
|
|
97
|
+
diff: ({ news, output }) =>
|
|
98
|
+
run(
|
|
99
|
+
Effect.gen(function* () {
|
|
100
|
+
const invalid = isResolved(news) ? validateCloudflaredTunnel(news) : undefined;
|
|
101
|
+
if (invalid !== undefined) return yield* Effect.fail(invalid);
|
|
102
|
+
return diffCloudflaredTunnel(news, output, yield* accountId);
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
read: ({ olds, output }) =>
|
|
106
|
+
run(Effect.flatMap(accountId, (account) => readCloudflaredTunnel(account, olds, output))),
|
|
107
|
+
reconcile: ({ news, output }) =>
|
|
108
|
+
run(
|
|
109
|
+
Effect.flatMap(accountId, (account) =>
|
|
110
|
+
reconcileCloudflaredTunnel(account, news, output),
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
delete: ({ output }) => run(deleteCloudflaredTunnel(output)),
|
|
114
|
+
});
|
|
115
|
+
}),
|
|
116
|
+
);
|