authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:30:03-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:30:03-07:00
logd7ff7e73336482d64449f72a4bd31d9ce9f4c01e
tree9db8825bf67c97a0baf7d127f56105f7096d27d1
parentbd8e3ee008e4c78085ebfbf47bf216389a92ac75
signature Signed by PGP key B5690EEEBB952194

[codex] Audit managed endpoint error context (#3245)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 678 insertions(+), 104 deletions(-)

infra/relay/src/environments/ManagedEndpointAllocations.test.ts created+93
......@@ -0,0 +1,93 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as RelayDb from "../db.ts";
6import { relayManagedEndpointAllocations } from "../persistence/schema.ts";
7import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
8
9const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
10 ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db)));
11
12describe("ManagedEndpointAllocations", () => {
13 it.effect("retains database failures with allocation operation and identity", () => {
14 const cause = new Error("database unavailable");
15 const fakeDb = {
16 select: () => ({
17 from: (table: unknown) => {
18 expect(table).toBe(relayManagedEndpointAllocations);
19 return {
20 where: () => ({
21 limit: () => Effect.fail(cause),
22 }),
23 };
24 },
25 }),
26 } as unknown as RelayDb.RelayDb["Service"];
27
28 return Effect.gen(function* () {
29 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
30 const error = yield* Effect.flip(
31 allocations.get({ userId: "user-1", environmentId: "environment-1" }),
32 );
33
34 expect(error).toMatchObject({
35 _tag: "ManagedEndpointAllocationPersistenceError",
36 operation: "get",
37 stage: "database-request",
38 userId: "user-1",
39 environmentId: "environment-1",
40 });
41 expect(error.cause).toBe(cause);
42 }).pipe(Effect.provide(layerWithDb(fakeDb)));
43 });
44
45 it.effect("reports an unresolved reservation without manufacturing a cause", () => {
46 const fakeDb = {
47 insert: (table: unknown) => {
48 expect(table).toBe(relayManagedEndpointAllocations);
49 return {
50 values: () => ({
51 onConflictDoNothing: () => ({
52 returning: () => Effect.succeed([]),
53 }),
54 }),
55 };
56 },
57 select: () => ({
58 from: (table: unknown) => {
59 expect(table).toBe(relayManagedEndpointAllocations);
60 return {
61 where: () => ({
62 limit: () => Effect.succeed([]),
63 }),
64 };
65 },
66 }),
67 } as unknown as RelayDb.RelayDb["Service"];
68
69 return Effect.gen(function* () {
70 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
71 const error = yield* Effect.flip(
72 allocations.reserve({
73 userId: "user-1",
74 environmentId: "environment-1",
75 hostname: "environment-1.example.test",
76 tunnelName: "environment-1-tunnel",
77 }),
78 );
79
80 expect(error).toMatchObject({
81 _tag: "ManagedEndpointAllocationPersistenceError",
82 operation: "reserve",
83 stage: "resolve-reservation",
84 userId: "user-1",
85 environmentId: "environment-1",
86 hostname: "environment-1.example.test",
87 tunnelName: "environment-1-tunnel",
88 });
89 expect(error.cause).toBeUndefined();
90 expect(error.message).toContain("'resolve-reservation'");
91 }).pipe(Effect.provide(layerWithDb(fakeDb)));
92 });
93});
infra/relay/src/environments/ManagedEndpointAllocations.ts+110-23
......@@ -38,15 +38,29 @@ export function resolveReadyManagedEndpoint(input: {
3838
3939export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErrorClass<ManagedEndpointAllocationPersistenceError>()(
4040 "ManagedEndpointAllocationPersistenceError",
41 { cause: Schema.Defect() },
41 {
42 operation: Schema.Literals([
43 "get",
44 "reserve",
45 "record-tunnel",
46 "record-dns",
47 "mark-ready",
48 "remove",
49 ]),
50 stage: Schema.Literals(["database-request", "resolve-reservation"]),
51 userId: Schema.String,
52 environmentId: Schema.String,
53 hostname: Schema.optionalKey(Schema.String),
54 tunnelName: Schema.optionalKey(Schema.String),
55 tunnelId: Schema.optionalKey(Schema.String),
56 dnsRecordId: Schema.optionalKey(Schema.String),
57 cause: Schema.optional(Schema.Defect()),
58 },
4259) {
4360 override get message(): string {
44 return "Failed to persist managed endpoint allocation";
61 return `Managed endpoint allocation '${this.operation}' failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
4562 }
4663}
47const isManagedEndpointAllocationPersistenceError = Schema.is(
48 ManagedEndpointAllocationPersistenceError,
49);
5064
5165interface ManagedEndpointAllocationKey {
5266 readonly userId: string;
......@@ -106,11 +120,6 @@ const whereAllocation = (input: ManagedEndpointAllocationKey) =>
106120 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),
107121 );
108122
109const persistenceError = (cause: unknown) =>
110 isManagedEndpointAllocationPersistenceError(cause)
111 ? cause
112 : new ManagedEndpointAllocationPersistenceError({ cause });
113
114123const make = Effect.gen(function* () {
115124 const db = yield* RelayDb.RelayDb;
116125
......@@ -125,7 +134,15 @@ const make = Effect.gen(function* () {
125134 .limit(1)
126135 .pipe(
127136 Effect.map((rows) => rows[0] ?? null),
128 Effect.mapError(persistenceError),
137 Effect.mapError(
138 (cause) =>
139 new ManagedEndpointAllocationPersistenceError({
140 operation: "get",
141 stage: "database-request",
142 ...input,
143 cause,
144 }),
145 ),
129146 );
130147 }),
131148 reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* (
......@@ -140,7 +157,18 @@ const make = Effect.gen(function* () {
140157 updatedAt: now,
141158 })
142159 .onConflictDoNothing()
143 .returning(allocationSelection);
160 .returning(allocationSelection)
161 .pipe(
162 Effect.mapError(
163 (cause) =>
164 new ManagedEndpointAllocationPersistenceError({
165 operation: "reserve",
166 stage: "database-request",
167 ...input,
168 cause,
169 }),
170 ),
171 );
144172
145173 const allocation =
146174 inserted[0] ??
......@@ -149,16 +177,29 @@ const make = Effect.gen(function* () {
149177 .from(relayManagedEndpointAllocations)
150178 .where(whereAllocation(input))
151179 .limit(1)
152 .pipe(Effect.map((rows) => rows[0])));
180 .pipe(
181 Effect.map((rows) => rows[0]),
182 Effect.mapError(
183 (cause) =>
184 new ManagedEndpointAllocationPersistenceError({
185 operation: "reserve",
186 stage: "database-request",
187 ...input,
188 cause,
189 }),
190 ),
191 ));
153192
154193 if (allocation === undefined) {
155194 return yield* new ManagedEndpointAllocationPersistenceError({
156 cause: new Error("Managed endpoint allocation was not persisted."),
195 operation: "reserve",
196 stage: "resolve-reservation",
197 ...input,
157198 });
158199 }
159200
160201 return allocation;
161 }, Effect.mapError(persistenceError)),
202 }),
162203 recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* (
163204 input: RecordManagedEndpointTunnelInput,
164205 ) {
......@@ -168,8 +209,19 @@ const make = Effect.gen(function* () {
168209 tunnelId: input.tunnelId,
169210 updatedAt: DateTime.formatIso(yield* DateTime.now),
170211 })
171 .where(whereAllocation(input));
172 }, Effect.mapError(persistenceError)),
212 .where(whereAllocation(input))
213 .pipe(
214 Effect.mapError(
215 (cause) =>
216 new ManagedEndpointAllocationPersistenceError({
217 operation: "record-tunnel",
218 stage: "database-request",
219 ...input,
220 cause,
221 }),
222 ),
223 );
224 }),
173225 recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* (
174226 input: RecordManagedEndpointDnsInput,
175227 ) {
......@@ -179,8 +231,19 @@ const make = Effect.gen(function* () {
179231 dnsRecordId: input.dnsRecordId,
180232 updatedAt: DateTime.formatIso(yield* DateTime.now),
181233 })
182 .where(whereAllocation(input));
183 }, Effect.mapError(persistenceError)),
234 .where(whereAllocation(input))
235 .pipe(
236 Effect.mapError(
237 (cause) =>
238 new ManagedEndpointAllocationPersistenceError({
239 operation: "record-dns",
240 stage: "database-request",
241 ...input,
242 cause,
243 }),
244 ),
245 );
246 }),
184247 markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* (
185248 input: ManagedEndpointAllocationKey,
186249 ) {
......@@ -191,13 +254,37 @@ const make = Effect.gen(function* () {
191254 readyAt: now,
192255 updatedAt: now,
193256 })
194 .where(whereAllocation(input));
195 }, Effect.mapError(persistenceError)),
257 .where(whereAllocation(input))
258 .pipe(
259 Effect.mapError(
260 (cause) =>
261 new ManagedEndpointAllocationPersistenceError({
262 operation: "mark-ready",
263 stage: "database-request",
264 ...input,
265 cause,
266 }),
267 ),
268 );
269 }),
196270 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
197271 input: ManagedEndpointAllocationKey,
198272 ) {
199 yield* db.delete(relayManagedEndpointAllocations).where(whereAllocation(input));
200 }, Effect.mapError(persistenceError)),
273 yield* db
274 .delete(relayManagedEndpointAllocations)
275 .where(whereAllocation(input))
276 .pipe(
277 Effect.mapError(
278 (cause) =>
279 new ManagedEndpointAllocationPersistenceError({
280 operation: "remove",
281 stage: "database-request",
282 ...input,
283 cause,
284 }),
285 ),
286 );
287 }),
201288 });
202289});
203290
infra/relay/src/environments/ManagedEndpointProvider.test.ts+83-5
......@@ -130,6 +130,9 @@ function makeDnsClient(
130130 calls.push({ operation: "updateRecord", input: { dnsRecordId, request } });
131131 if (!currentRecords.some((record) => record.id === dnsRecordId)) {
132132 return yield* new ManagedEndpointProvider.ManagedEndpointDnsClientError({
133 operation: "update-record",
134 hostname: request.name,
135 dnsRecordId,
133136 cause: `DNS record ${dnsRecordId} does not exist.`,
134137 });
135138 }
......@@ -398,7 +401,13 @@ describe("ManagedEndpointProvider", () => {
398401 expect(dnsCalls).toHaveLength(0);
399402 expect(result._tag).toBe("Failure");
400403 if (result._tag === "Failure") {
401 expect(result.failure._tag).toBe("ManagedEndpointOriginNotAllowed");
404 expect(result.failure).toMatchObject({
405 _tag: "ManagedEndpointOriginNotAllowed",
406 userId: "user_ABC",
407 environmentId: "env_ABC",
408 host: "192.168.1.10",
409 port: 3773,
410 });
402411 }
403412 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), makeDnsClient(dnsCalls))));
404413 });
......@@ -593,6 +602,8 @@ describe("ManagedEndpointProvider", () => {
593602 const tunnelCalls: TunnelCall[] = [];
594603 let deleteAttempts = 0;
595604 const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
605 operation: "delete",
606 tunnelId: "tunnel-id",
596607 cause: "Cloudflare tunnel deletion failed",
597608 });
598609 const tunnels = makePersistentTunnelClient(tunnelCalls);
......@@ -618,6 +629,16 @@ describe("ManagedEndpointProvider", () => {
618629 });
619630 const first = yield* Effect.result(provider.deprovision(key));
620631 expect(first._tag).toBe("Failure");
632 if (first._tag === "Failure") {
633 expect(first.failure).toMatchObject({
634 _tag: "ManagedEndpointDeprovisioningFailed",
635 stage: "delete-tunnel",
636 userId: key.userId,
637 environmentId: key.environmentId,
638 tunnelId: "tunnel-id",
639 });
640 expect(first.failure.cause).toBe(failure);
641 }
621642 yield* provider.deprovision(key);
622643
623644 expect(allocationCalls.map((call) => call.operation)).toEqual([
......@@ -639,13 +660,23 @@ describe("ManagedEndpointProvider", () => {
639660 ...makeTunnelClient(),
640661 delete: () =>
641662 Effect.fail(
642 new ManagedEndpointProvider.ManagedEndpointTunnelClientError({ cause: notFound }),
663 new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
664 operation: "delete",
665 tunnelId: "tunnel-id",
666 cause: notFound,
667 }),
643668 ),
644669 });
645670 const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({
646671 ...makeDnsClient(),
647672 deleteRecord: () =>
648 Effect.fail(new ManagedEndpointProvider.ManagedEndpointDnsClientError({ cause: notFound })),
673 Effect.fail(
674 new ManagedEndpointProvider.ManagedEndpointDnsClientError({
675 operation: "delete-record",
676 dnsRecordId: "created-record-id",
677 cause: notFound,
678 }),
679 ),
649680 });
650681 const layer = providerLayer(tunnelClient, dnsClient, makeAllocations(allocationCalls));
651682
......@@ -690,6 +721,8 @@ describe("ManagedEndpointProvider", () => {
690721 it.effect("recovers when DNS creation reports failure after the record became visible", () => {
691722 const dnsCalls: DnsCall[] = [];
692723 const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({
724 operation: "create-record",
725 hostname: expectedManagedHostname("env_ABC"),
693726 cause: "ambiguous Cloudflare DNS response",
694727 });
695728 let records: ReadonlyArray<{ readonly id: string }> = [];
......@@ -732,8 +765,43 @@ describe("ManagedEndpointProvider", () => {
732765 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient)));
733766 });
734767
768 it.effect("reports malformed tunnel responses without manufacturing a cause", () => {
769 const dnsCalls: DnsCall[] = [];
770 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
771 ...makeTunnelClient(),
772 create: () => Effect.succeed({ id: "returned-tunnel-id", name: null }),
773 });
774
775 return Effect.gen(function* () {
776 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
777 const error = yield* Effect.flip(
778 provider.provision({
779 userId: "user_ABC",
780 environmentId: "env_ABC",
781 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
782 }),
783 );
784
785 expect(error).toMatchObject({
786 _tag: "ManagedEndpointProvisioningFailed",
787 stage: "validate-tunnel-response",
788 userId: "user_ABC",
789 environmentId: "env_ABC",
790 hostname: expectedManagedHostname("env_ABC"),
791 tunnelName: expectedManagedTunnelName("env_ABC"),
792 returnedTunnelId: "returned-tunnel-id",
793 });
794 if (error._tag === "ManagedEndpointProvisioningFailed") {
795 expect(error.cause).toBeUndefined();
796 }
797 expect(dnsCalls).toHaveLength(0);
798 }).pipe(Effect.provide(providerLayer(tunnelClient, makeDnsClient(dnsCalls))));
799 });
800
735801 it.effect("fails provisioning when the DNS client fails", () => {
736802 const failure = new ManagedEndpointProvider.ManagedEndpointDnsClientError({
803 operation: "list-records",
804 hostname: expectedManagedHostname("env_ABC"),
737805 cause: "Cloudflare DNS failure",
738806 });
739807 const dnsClient = ManagedEndpointProvider.ManagedEndpointDnsClient.of({
......@@ -753,8 +821,18 @@ describe("ManagedEndpointProvider", () => {
753821 }),
754822 );
755823
756 expect(error._tag).toBe("ManagedEndpointProvisioningFailed");
757 expect(error.cause).toBe(failure);
824 expect(error).toMatchObject({
825 _tag: "ManagedEndpointProvisioningFailed",
826 stage: "ensure-dns-record",
827 userId: "user_ABC",
828 environmentId: "env_ABC",
829 hostname: expectedManagedHostname("env_ABC"),
830 tunnelName: expectedManagedTunnelName("env_ABC"),
831 tunnelId: "tunnel-id",
832 });
833 if (error._tag === "ManagedEndpointProvisioningFailed") {
834 expect(error.cause).toBe(failure);
835 }
758836 }).pipe(Effect.provide(providerLayer(makeTunnelClient(), dnsClient)));
759837 });
760838});
infra/relay/src/environments/ManagedEndpointProvider.ts+392-76
......@@ -7,7 +7,6 @@ import * as Effect from "effect/Effect";
77import * as Encoding from "effect/Encoding";
88import * as Layer from "effect/Layer";
99import * as Option from "effect/Option";
10import * as Result from "effect/Result";
1110import * as Schema from "effect/Schema";
1211
1312import type {
......@@ -27,40 +26,86 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts";
2726
2827export class ManagedEndpointProvisioningNotConfigured extends Schema.TaggedErrorClass<ManagedEndpointProvisioningNotConfigured>()(
2928 "ManagedEndpointProvisioningNotConfigured",
30 {},
29 {
30 userId: Schema.String,
31 environmentId: Schema.String,
32 missingSettings: Schema.Array(
33 Schema.Literals(["managedEndpointBaseDomain", "managedEndpointNamespace"]),
34 ),
35 },
3136) {
3237 override get message(): string {
33 return "Managed endpoint provisioning is not configured";
38 return `Managed endpoint provisioning is not configured for user '${this.userId}', environment '${this.environmentId}': missing ${this.missingSettings.join(", ")}`;
3439 }
3540}
3641
42const ManagedEndpointProvisioningStage = Schema.Literals([
43 "derive-environment-hash",
44 "reserve-allocation",
45 "ensure-tunnel",
46 "validate-tunnel-response",
47 "record-tunnel",
48 "configure-tunnel",
49 "ensure-dns-record",
50 "record-dns",
51 "get-tunnel-token",
52 "mark-allocation-ready",
53]);
54
3755export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<ManagedEndpointProvisioningFailed>()(
3856 "ManagedEndpointProvisioningFailed",
39 { cause: Schema.Defect() },
57 {
58 stage: ManagedEndpointProvisioningStage,
59 userId: Schema.String,
60 environmentId: Schema.String,
61 hostname: Schema.optionalKey(Schema.String),
62 tunnelName: Schema.optionalKey(Schema.String),
63 tunnelId: Schema.optionalKey(Schema.String),
64 dnsRecordId: Schema.optionalKey(Schema.String),
65 returnedTunnelName: Schema.optionalKey(Schema.String),
66 returnedTunnelId: Schema.optionalKey(Schema.String),
67 cause: Schema.optional(Schema.Defect()),
68 },
4069) {
4170 override get message(): string {
42 return "Managed endpoint provisioning failed";
71 return `Managed endpoint provisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
4372 }
4473}
4574
75const ManagedEndpointDeprovisioningStage = Schema.Literals([
76 "load-allocation",
77 "delete-dns-record",
78 "delete-tunnel",
79 "remove-allocation",
80]);
81
4682export class ManagedEndpointDeprovisioningFailed extends Schema.TaggedErrorClass<ManagedEndpointDeprovisioningFailed>()(
4783 "ManagedEndpointDeprovisioningFailed",
48 { cause: Schema.Defect() },
84 {
85 stage: ManagedEndpointDeprovisioningStage,
86 userId: Schema.String,
87 environmentId: Schema.String,
88 tunnelId: Schema.optionalKey(Schema.String),
89 dnsRecordId: Schema.optionalKey(Schema.String),
90 cause: Schema.Defect(),
91 },
4992) {
5093 override get message(): string {
51 return "Managed endpoint deprovisioning failed";
94 return `Managed endpoint deprovisioning failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
5295 }
5396}
5497
5598export class ManagedEndpointOriginNotAllowed extends Schema.TaggedErrorClass<ManagedEndpointOriginNotAllowed>()(
5699 "ManagedEndpointOriginNotAllowed",
57100 {
101 userId: Schema.String,
102 environmentId: Schema.String,
58103 host: Schema.String,
59104 port: Schema.Number,
60105 },
61106) {
62107 override get message(): string {
63 return `Managed endpoint origin '${this.host}:${this.port}' is not allowed`;
108 return `Managed endpoint origin '${this.host}:${this.port}' is not allowed for user '${this.userId}', environment '${this.environmentId}'`;
64109 }
65110}
66111
......@@ -94,12 +139,26 @@ interface ManagedEndpointTunnel {
94139 readonly name?: string | null;
95140}
96141
142const ManagedEndpointTunnelClientOperation = Schema.Literals([
143 "list",
144 "create",
145 "put-configuration",
146 "get-token",
147 "delete",
148]);
149
97150export class ManagedEndpointTunnelClientError extends Schema.TaggedErrorClass<ManagedEndpointTunnelClientError>()(
98151 "ManagedEndpointTunnelClientError",
99 { cause: Schema.Defect() },
152 {
153 operation: ManagedEndpointTunnelClientOperation,
154 tunnelName: Schema.optionalKey(Schema.String),
155 tunnelId: Schema.optionalKey(Schema.String),
156 cause: Schema.Defect(),
157 },
100158) {
101159 override get message(): string {
102 return "Managed endpoint tunnel provider request failed";
160 const target = this.tunnelId ?? this.tunnelName;
161 return `Managed endpoint tunnel provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`;
103162 }
104163}
105164
......@@ -147,12 +206,25 @@ interface ManagedEndpointCnameRecordInput {
147206 readonly proxied: true;
148207}
149208
209const ManagedEndpointDnsClientOperation = Schema.Literals([
210 "list-records",
211 "create-record",
212 "update-record",
213 "delete-record",
214]);
215
150216export class ManagedEndpointDnsClientError extends Schema.TaggedErrorClass<ManagedEndpointDnsClientError>()(
151217 "ManagedEndpointDnsClientError",
152 { cause: Schema.Defect() },
218 {
219 operation: ManagedEndpointDnsClientOperation,
220 hostname: Schema.optionalKey(Schema.String),
221 dnsRecordId: Schema.optionalKey(Schema.String),
222 cause: Schema.Defect(),
223 },
153224) {
154225 override get message(): string {
155 return "Managed endpoint DNS provider request failed";
226 const target = this.dnsRecordId ?? this.hostname;
227 return `Managed endpoint DNS provider '${this.operation}' request failed${target === undefined ? "" : ` for '${target}'`}`;
156228 }
157229}
158230
......@@ -183,13 +255,26 @@ export const layerDnsClient = (client: ManagedEndpointDnsClient["Service"]) =>
183255
184256const requireCloudflareSettings = Effect.fnUntraced(function* (
185257 settings: RelayConfiguration.RelayConfiguration["Service"],
258 input: { readonly userId: string; readonly environmentId: string },
186259) {
187 if (!settings.managedEndpointBaseDomain || !settings.managedEndpointNamespace) {
188 return yield* new ManagedEndpointProvisioningNotConfigured();
260 const baseDomain = settings.managedEndpointBaseDomain;
261 const namespace = settings.managedEndpointNamespace;
262 const missingSettings: Array<"managedEndpointBaseDomain" | "managedEndpointNamespace"> = [];
263 if (!baseDomain) {
264 missingSettings.push("managedEndpointBaseDomain");
265 }
266 if (!namespace) {
267 missingSettings.push("managedEndpointNamespace");
268 }
269 if (!baseDomain || !namespace) {
270 return yield* new ManagedEndpointProvisioningNotConfigured({
271 ...input,
272 missingSettings,
273 });
189274 }
190275 return {
191 baseDomain: settings.managedEndpointBaseDomain,
192 namespace: settings.managedEndpointNamespace,
276 baseDomain,
277 namespace,
193278 };
194279});
195280
......@@ -231,10 +316,19 @@ function isNotFoundCause(cause: unknown): boolean {
231316 return "cause" in cause && isNotFoundCause(cause.cause);
232317}
233318
234const ignoreNotFound = <A, E>(effect: Effect.Effect<A, E>): Effect.Effect<void, E> =>
319type ManagedEndpointClientError = ManagedEndpointTunnelClientError | ManagedEndpointDnsClientError;
320
321const ignoreNotFound = <A>(
322 effect: Effect.Effect<A, ManagedEndpointClientError>,
323): Effect.Effect<void, ManagedEndpointClientError> =>
235324 effect.pipe(
236325 Effect.asVoid,
237 Effect.catch((cause) => (isNotFoundCause(cause) ? Effect.void : Effect.fail(cause))),
326 Effect.catchTags({
327 ManagedEndpointTunnelClientError: (error) =>
328 isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error),
329 ManagedEndpointDnsClientError: (error) =>
330 isNotFoundCause(error.cause) ? Effect.void : Effect.fail(error),
331 }),
238332 );
239333
240334const make = Effect.gen(function* () {
......@@ -289,25 +383,26 @@ const make = Effect.gen(function* () {
289383 }
290384 return yield* dns.createRecord(dnsRecord).pipe(
291385 Effect.map((record) => record.id),
292 Effect.catch((createError) =>
293 Effect.gen(function* () {
294 let records = yield* dns.listRecords(hostname);
295 for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) {
296 yield* Effect.sleep("200 millis");
297 records = yield* dns.listRecords(hostname);
298 }
299 return records;
300 }).pipe(
301 Effect.flatMap((records) =>
302 records.length > 0
303 ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord)
304 : Effect.fail(createError),
305 ),
306 Effect.flatMap((dnsRecordId) =>
307 dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId),
386 Effect.catchTags({
387 ManagedEndpointDnsClientError: (createError) =>
388 Effect.gen(function* () {
389 let records = yield* dns.listRecords(hostname);
390 for (let attempt = 0; records.length === 0 && attempt < 4; attempt++) {
391 yield* Effect.sleep("200 millis");
392 records = yield* dns.listRecords(hostname);
393 }
394 return records;
395 }).pipe(
396 Effect.flatMap((records) =>
397 records.length > 0
398 ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord)
399 : Effect.fail(createError),
400 ),
401 Effect.flatMap((dnsRecordId) =>
402 dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId),
403 ),
308404 ),
309 ),
310 ),
405 }),
311406 );
312407 });
313408
......@@ -317,25 +412,59 @@ const make = Effect.gen(function* () {
317412 "relay.user_id": input.userId,
318413 "relay.environment_id": input.environmentId,
319414 });
320 const allocation = yield* allocations
321 .get(input)
322 .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })));
415 const allocation = yield* allocations.get(input).pipe(
416 Effect.mapError(
417 (cause) =>
418 new ManagedEndpointDeprovisioningFailed({
419 ...input,
420 stage: "load-allocation",
421 cause,
422 }),
423 ),
424 );
323425 if (allocation === null) {
324426 return;
325427 }
326 if (allocation.dnsRecordId !== null) {
327 yield* ignoreNotFound(dns.deleteRecord(allocation.dnsRecordId)).pipe(
328 Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })),
428 const dnsRecordId = allocation.dnsRecordId;
429 if (dnsRecordId !== null) {
430 yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe(
431 Effect.mapError(
432 (cause) =>
433 new ManagedEndpointDeprovisioningFailed({
434 ...input,
435 stage: "delete-dns-record",
436 dnsRecordId,
437 cause,
438 }),
439 ),
329440 );
330441 }
331 if (allocation.tunnelId !== null) {
332 yield* ignoreNotFound(tunnels.delete(allocation.tunnelId)).pipe(
333 Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })),
442 const tunnelId = allocation.tunnelId;
443 if (tunnelId !== null) {
444 yield* ignoreNotFound(tunnels.delete(tunnelId)).pipe(
445 Effect.mapError(
446 (cause) =>
447 new ManagedEndpointDeprovisioningFailed({
448 ...input,
449 stage: "delete-tunnel",
450 tunnelId,
451 cause,
452 }),
453 ),
334454 );
335455 }
336 yield* allocations
337 .remove(input)
338 .pipe(Effect.mapError((cause) => new ManagedEndpointDeprovisioningFailed({ cause })));
456 yield* allocations.remove(input).pipe(
457 Effect.mapError(
458 (cause) =>
459 new ManagedEndpointDeprovisioningFailed({
460 ...input,
461 stage: "remove-allocation",
462 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),
463 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),
464 cause,
465 }),
466 ),
467 );
339468 }),
340469 provision: Effect.fn("relay.managed_endpoint_provider.provision")(function* (input) {
341470 yield* Effect.annotateCurrentSpan({
......@@ -346,11 +475,13 @@ const make = Effect.gen(function* () {
346475 });
347476 if (!isLoopbackOrigin(input.origin)) {
348477 return yield* new ManagedEndpointOriginNotAllowed({
478 userId: input.userId,
479 environmentId: input.environmentId,
349480 host: input.origin.localHttpHost,
350481 port: input.origin.localHttpPort,
351482 });
352483 }
353 const cf = yield* requireCloudflareSettings(config);
484 const cf = yield* requireCloudflareSettings(config, input);
354485 const environmentHash = yield* crypto
355486 .digest(
356487 "SHA-256",
......@@ -360,19 +491,45 @@ const make = Effect.gen(function* () {
360491 )
361492 .pipe(
362493 Effect.map(Encoding.encodeHex),
363 Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })),
494 Effect.mapError(
495 (cause) =>
496 new ManagedEndpointProvisioningFailed({
497 userId: input.userId,
498 environmentId: input.environmentId,
499 stage: "derive-environment-hash",
500 cause,
501 }),
502 ),
364503 );
504 const requestedHostname = managedEndpointHostname(
505 cf.namespace,
506 cf.baseDomain,
507 environmentHash,
508 );
509 const requestedTunnelName = managedEndpointTunnelName(cf.namespace, environmentHash);
365510 const allocation = yield* allocations
366511 .reserve({
367512 userId: input.userId,
368513 environmentId: input.environmentId,
369 hostname: managedEndpointHostname(cf.namespace, cf.baseDomain, environmentHash),
370 tunnelName: managedEndpointTunnelName(cf.namespace, environmentHash),
514 hostname: requestedHostname,
515 tunnelName: requestedTunnelName,
371516 })
372 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
517 .pipe(
518 Effect.mapError(
519 (cause) =>
520 new ManagedEndpointProvisioningFailed({
521 userId: input.userId,
522 environmentId: input.environmentId,
523 stage: "reserve-allocation",
524 hostname: requestedHostname,
525 tunnelName: requestedTunnelName,
526 cause,
527 }),
528 ),
529 );
373530 const { hostname, tunnelName } = allocation;
374531
375 const tunnel = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe(
532 const tunnelResponse = yield* tunnels.list({ name: tunnelName, isDeleted: false }).pipe(
376533 Effect.map((tunnels) => tunnels.result),
377534 Effect.map(Arr.findFirst((tunnel) => tunnel.name === tunnelName)),
378535 Effect.flatMap(
......@@ -381,20 +538,50 @@ const make = Effect.gen(function* () {
381538 onNone: () => tunnels.create({ name: tunnelName, configSrc: "cloudflare" }),
382539 }),
383540 ),
384 Effect.filterMapOrFail((tunnel) =>
385 tunnel.id && tunnel.name
386 ? Result.succeed({ id: tunnel.id, name: tunnel.name })
387 : Result.fail(new ManagedEndpointProvisioningFailed({ cause: tunnel })),
541 Effect.mapError(
542 (cause) =>
543 new ManagedEndpointProvisioningFailed({
544 userId: input.userId,
545 environmentId: input.environmentId,
546 stage: "ensure-tunnel",
547 hostname,
548 tunnelName,
549 cause,
550 }),
388551 ),
389 Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })),
390552 );
553 if (!tunnelResponse.id || !tunnelResponse.name) {
554 return yield* new ManagedEndpointProvisioningFailed({
555 userId: input.userId,
556 environmentId: input.environmentId,
557 stage: "validate-tunnel-response",
558 hostname,
559 tunnelName,
560 ...(tunnelResponse.id ? { returnedTunnelId: tunnelResponse.id } : {}),
561 ...(tunnelResponse.name ? { returnedTunnelName: tunnelResponse.name } : {}),
562 });
563 }
564 const tunnel = { id: tunnelResponse.id, name: tunnelResponse.name };
391565 yield* allocations
392566 .recordTunnel({
393567 userId: input.userId,
394568 environmentId: input.environmentId,
395569 tunnelId: tunnel.id,
396570 })
397 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
571 .pipe(
572 Effect.mapError(
573 (cause) =>
574 new ManagedEndpointProvisioningFailed({
575 userId: input.userId,
576 environmentId: input.environmentId,
577 stage: "record-tunnel",
578 hostname,
579 tunnelName,
580 tunnelId: tunnel.id,
581 cause,
582 }),
583 ),
584 );
398585
399586 yield* tunnels
400587 .putConfiguration(tunnel.id, {
......@@ -406,7 +593,20 @@ const make = Effect.gen(function* () {
406593 { service: "http_status:404" },
407594 ],
408595 })
409 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
596 .pipe(
597 Effect.mapError(
598 (cause) =>
599 new ManagedEndpointProvisioningFailed({
600 userId: input.userId,
601 environmentId: input.environmentId,
602 stage: "configure-tunnel",
603 hostname,
604 tunnelName,
605 tunnelId: tunnel.id,
606 cause,
607 }),
608 ),
609 );
410610
411611 const dnsRecord = {
412612 type: "CNAME",
......@@ -417,7 +617,19 @@ const make = Effect.gen(function* () {
417617 } as const;
418618
419619 const dnsRecordId = yield* ensureDnsRecord(hostname, allocation.dnsRecordId, dnsRecord).pipe(
420 Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })),
620 Effect.mapError(
621 (cause) =>
622 new ManagedEndpointProvisioningFailed({
623 userId: input.userId,
624 environmentId: input.environmentId,
625 stage: "ensure-dns-record",
626 hostname,
627 tunnelName,
628 tunnelId: tunnel.id,
629 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),
630 cause,
631 }),
632 ),
421633 );
422634 yield* allocations
423635 .recordDns({
......@@ -425,17 +637,57 @@ const make = Effect.gen(function* () {
425637 environmentId: input.environmentId,
426638 dnsRecordId,
427639 })
428 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
640 .pipe(
641 Effect.mapError(
642 (cause) =>
643 new ManagedEndpointProvisioningFailed({
644 userId: input.userId,
645 environmentId: input.environmentId,
646 stage: "record-dns",
647 hostname,
648 tunnelName,
649 tunnelId: tunnel.id,
650 dnsRecordId,
651 cause,
652 }),
653 ),
654 );
429655
430 const connectorToken = yield* tunnels
431 .getToken(tunnel.id)
432 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
656 const connectorToken = yield* tunnels.getToken(tunnel.id).pipe(
657 Effect.mapError(
658 (cause) =>
659 new ManagedEndpointProvisioningFailed({
660 userId: input.userId,
661 environmentId: input.environmentId,
662 stage: "get-tunnel-token",
663 hostname,
664 tunnelName,
665 tunnelId: tunnel.id,
666 dnsRecordId,
667 cause,
668 }),
669 ),
670 );
433671 yield* allocations
434672 .markReady({
435673 userId: input.userId,
436674 environmentId: input.environmentId,
437675 })
438 .pipe(Effect.mapError((cause) => new ManagedEndpointProvisioningFailed({ cause })));
676 .pipe(
677 Effect.mapError(
678 (cause) =>
679 new ManagedEndpointProvisioningFailed({
680 userId: input.userId,
681 environmentId: input.environmentId,
682 stage: "mark-allocation-ready",
683 hostname,
684 tunnelName,
685 tunnelId: tunnel.id,
686 dnsRecordId,
687 cause,
688 }),
689 ),
690 );
439691
440692 return {
441693 endpoint: managedEndpointForHostname(hostname),
......@@ -464,27 +716,62 @@ export const layerCloudflareBindings = (
464716 ManagedEndpointTunnelClient.of({
465717 list: (request) =>
466718 tunnelClient.list(request).pipe(
467 Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })),
719 Effect.mapError(
720 (cause) =>
721 new ManagedEndpointTunnelClientError({
722 operation: "list",
723 tunnelName: request.name,
724 cause,
725 }),
726 ),
468727 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
469728 ),
470729 create: (request) =>
471730 tunnelClient.create(request).pipe(
472 Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })),
731 Effect.mapError(
732 (cause) =>
733 new ManagedEndpointTunnelClientError({
734 operation: "create",
735 tunnelName: request.name,
736 cause,
737 }),
738 ),
473739 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
474740 ),
475741 putConfiguration: (tunnelId, config) =>
476742 tunnelClient.putConfiguration(tunnelId, config).pipe(
477 Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })),
743 Effect.mapError(
744 (cause) =>
745 new ManagedEndpointTunnelClientError({
746 operation: "put-configuration",
747 tunnelId,
748 cause,
749 }),
750 ),
478751 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
479752 ),
480753 getToken: (tunnelId) =>
481754 tunnelClient.getToken(tunnelId).pipe(
482 Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })),
755 Effect.mapError(
756 (cause) =>
757 new ManagedEndpointTunnelClientError({
758 operation: "get-token",
759 tunnelId,
760 cause,
761 }),
762 ),
483763 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
484764 ),
485765 delete: (tunnelId) =>
486766 tunnelClient.delete(tunnelId).pipe(
487 Effect.mapError((cause) => new ManagedEndpointTunnelClientError({ cause })),
767 Effect.mapError(
768 (cause) =>
769 new ManagedEndpointTunnelClientError({
770 operation: "delete",
771 tunnelId,
772 cause,
773 }),
774 ),
488775 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
489776 ),
490777 }),
......@@ -500,23 +787,52 @@ export const layerCloudflareBindings = (
500787 normalizeHostname(record.name) === normalizeHostname(hostname),
501788 ),
502789 ),
503 Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })),
790 Effect.mapError(
791 (cause) =>
792 new ManagedEndpointDnsClientError({
793 operation: "list-records",
794 hostname,
795 cause,
796 }),
797 ),
504798 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
505799 ),
506800 createRecord: (request) =>
507801 dnsClient.createDnsRecord(request).pipe(
508802 Effect.map((response) => ({ id: response.id })),
509 Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })),
803 Effect.mapError(
804 (cause) =>
805 new ManagedEndpointDnsClientError({
806 operation: "create-record",
807 hostname: request.name,
808 cause,
809 }),
810 ),
510811 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
511812 ),
512813 updateRecord: (dnsRecordId, request) =>
513814 dnsClient.updateDnsRecord(dnsRecordId, request).pipe(
514 Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })),
815 Effect.mapError(
816 (cause) =>
817 new ManagedEndpointDnsClientError({
818 operation: "update-record",
819 hostname: request.name,
820 dnsRecordId,
821 cause,
822 }),
823 ),
515824 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
516825 ),
517826 deleteRecord: (dnsRecordId) =>
518827 dnsClient.deleteDnsRecord(dnsRecordId).pipe(
519 Effect.mapError((cause) => new ManagedEndpointDnsClientError({ cause })),
828 Effect.mapError(
829 (cause) =>
830 new ManagedEndpointDnsClientError({
831 operation: "delete-record",
832 dnsRecordId,
833 cause,
834 }),
835 ),
520836 Effect.provideService(Alchemy.RuntimeContext, alchemyRuntimeContext),
521837 ),
522838 }),