1import * as eventarc from "@distilled.cloud/gcp/eventarc_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 compact,
19 collectPages,
20 expandResource,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 toPhysicalId,
28 userLabels,
29 retryOnTransient,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "channelConnections";
36
37export type ChannelConnectionProps = {
38 /**
39 * Channel connection id (the `{channelConnection}` segment of
40 * `projects/{project}/locations/{location}/channelConnections/{channelConnection}`).
41 * If omitted, a unique name is generated. Must match
42 * `[a-z]([a-z0-9-]*[a-z0-9])?` and be 1-63 characters. Immutable —
43 * changing it replaces the connection.
44 */
45 channelConnectionId?: string;
46 /**
47 * Eventarc location (`us-central1`, `us-east1`, …). Immutable —
48 * changing it replaces the connection. `US-CENTRAL1` is accepted and
49 * normalized to `us-central1`.
50 * @default the stack's GCP region (`GCP.Region`, else the profile
51 * region, else `us-central1`)
52 */
53 location?: string;
54 /**
55 * Subscriber Channel this connection binds, as
56 * `projects/{project}/locations/{location}/channels/{channel}` or the
57 * `{channel}` segment. Created in the provider project; the channel
58 * lives in the subscriber project. Immutable — changing it replaces
59 * the connection.
60 */
61 channel: string;
62 /**
63 * Activation token from the subscriber Channel. Input only — used
64 * during create to bind the channel to this provider project, then
65 * discarded. Omit when the API no longer requires it.
66 */
67 activationToken?: string;
68 /**
69 * User labels. Alchemy ownership labels are merged in automatically.
70 * The ChannelConnection API has no update method, so changing labels
71 * replaces the connection.
72 */
73 labels?: Record<string, string>;
74};
75
76export type ChannelConnection = Resource<
77 "GCP.Eventarc.ChannelConnection",
78 ChannelConnectionProps,
79 {
80 /** Full resource name `projects/{project}/locations/{location}/channelConnections/{channelConnection}`. */
81 name: string;
82 /** Channel connection id (last path segment). */
83 channelConnectionId: string;
84 /** Project id. */
85 project: string;
86 /** Location id (`us-central1`, …). */
87 location: string;
88 /** Bound subscriber Channel resource name. */
89 channel: string | undefined;
90 /** User labels (Alchemy ownership labels stripped). */
91 labels: Record<string, string>;
92 /** Server-assigned UUID4, stable until delete. */
93 uid: string | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * An Eventarc ChannelConnection that binds a third-party event provider
105 * to a subscriber Channel. Created in the provider project using the
106 * subscriber Channel's activation token.
107 *
108 * There is no patch API — `channelConnectionId`, `location`, `channel`,
109 * and labels are all identity. Changing any of them replaces the
110 * connection.
111 *
112 * ### Creating a ChannelConnection
113 * **Example:** Bind a subscriber channel
114 * ```typescript
115 * const connection = yield* GCP.Eventarc.ChannelConnection("Partner", {
116 * location: "us-central1",
117 * channel:
118 * "projects/subscriber/locations/us-central1/channels/datadog",
119 * activationToken: channel.activationToken,
120 * labels: { env: "prod" },
121 * });
122 * ```
123 *
124 * @resource
125 * @category Eventarc
126 */
127export const ChannelConnection = Resource<ChannelConnection>(
128 "GCP.Eventarc.ChannelConnection",
129);
130
131const toAttrs = (
132 connection: eventarc.ChannelConnection,
133 project: string,
134 region: string,
135) => {
136 const name = connection.name ?? "";
137 const parsed = parseName(name, COLLECTION, region);
138 return {
139 name,
140 channelConnectionId: parsed.id,
141 project: parsed.project || project,
142 location: parsed.location,
143 channel: connection.channel,
144 labels: userLabels(connection.labels),
145 uid: connection.uid,
146 createTime: connection.createTime,
147 updateTime: connection.updateTime,
148 };
149};
150
151const getByName = (name: string) =>
152 eventarc
153 .getProjectsLocationsChannelConnections({ name })
154 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
155
156export const ChannelConnectionProvider = () =>
157 Provider.succeed(ChannelConnection, {
158 stables: [
159 "name",
160 "channelConnectionId",
161 "project",
162 "location",
163 "channel",
164 "uid",
165 "createTime",
166 ],
167
168 diff: Effect.fn(function* ({ news, olds, output }) {
169 if (!isResolved(news)) return undefined;
170 const env = yield* GcpEnvironment.current;
171 const previousId =
172 olds?.channelConnectionId ?? output?.channelConnectionId;
173 const nextId = news.channelConnectionId
174 ? rfc1035(news.channelConnectionId, "channel-connection")
175 : previousId;
176 const previousLocation = normalizeLocation(
177 olds?.location ?? output?.location,
178 env.region,
179 );
180 const nextLocation = normalizeLocation(
181 news.location ?? olds?.location ?? output?.location,
182 env.region,
183 );
184 const previousChannel = olds?.channel ?? output?.channel ?? "";
185 const nextChannel = news.channel;
186 const previousLabels = toLabels(olds?.labels ?? output?.labels);
187 const nextLabels = toLabels(news.labels);
188 const { upsert, removed } = diffLabels(previousLabels, nextLabels);
189 const labelsChanged = upsert.length > 0 || removed.length > 0;
190 const identityChanged =
191 (previousId !== undefined &&
192 nextId !== undefined &&
193 nextId !== previousId) ||
194 previousLocation !== nextLocation ||
195 (previousChannel.length > 0 && previousChannel !== nextChannel);
196 if (!identityChanged && !labelsChanged) return undefined;
197 const samePhysical =
198 previousId !== undefined &&
199 nextId !== undefined &&
200 previousId === nextId &&
201 previousLocation === nextLocation;
202 return {
203 action: "replace" as const,
204 deleteFirst: samePhysical,
205 };
206 }),
207
208 read: Effect.fn(function* ({ id, olds, output }) {
209 const env = yield* GcpEnvironment.current;
210 const channelConnectionId = yield* toPhysicalId(
211 id,
212 olds?.channelConnectionId,
213 output?.channelConnectionId,
214 "channel-connection",
215 );
216 const location = normalizeLocation(
217 olds?.location ?? output?.location,
218 env.region,
219 );
220 const name =
221 output?.name ??
222 resourceName(env.project, location, COLLECTION, channelConnectionId);
223 const existing = yield* getByName(name);
224 if (existing === undefined) return undefined;
225 const attrs = toAttrs(existing, env.project, env.region);
226 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
227 ? attrs
228 : Unowned(attrs);
229 }),
230
231 list: () =>
232 Effect.gen(function* () {
233 const env = yield* GcpEnvironment.current;
234 const items = yield* collectPages(
235 eventarc.listProjectsLocationsChannelConnections.pages({
236 parent: parentOf(env.project, "-"),
237 pageSize: 1000,
238 }),
239 (page) => page.channelConnections,
240 );
241 return items
242 .filter((item) => hasAlchemyLabelKeys(item.labels))
243 .map((item) => toAttrs(item, env.project, env.region));
244 }),
245
246 reconcile: Effect.fn(function* ({ id, news, output }) {
247 const env = yield* GcpEnvironment.current;
248 const channelConnectionId = yield* toPhysicalId(
249 id,
250 news.channelConnectionId,
251 output?.channelConnectionId,
252 "channel-connection",
253 );
254 const location = normalizeLocation(
255 news.location ?? output?.location,
256 env.region,
257 );
258 const name = resourceName(
259 env.project,
260 location,
261 COLLECTION,
262 channelConnectionId,
263 );
264 const desiredLabels = {
265 ...toLabels(news.labels),
266 ...(yield* createInternalLabels(id)),
267 };
268 const channel = expandResource(
269 news.channel,
270 env.project,
271 location,
272 "channels",
273 );
274
275 let current = yield* getByName(name);
276
277 if (current === undefined) {
278 const created = yield* eventarc
279 .createProjectsLocationsChannelConnections({
280 parent: parentOf(env.project, location),
281 channelConnectionId,
282 body: compact({
283 name,
284 channel,
285 activationToken: news.activationToken,
286 labels: desiredLabels,
287 }),
288 })
289 .pipe(
290 Effect.retry({
291 while: (error) => error._tag === "Conflict",
292 times: 5,
293 schedule: Schedule.spaced("2 seconds"),
294 }),
295 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
296 );
297 if (created !== undefined) {
298 yield* waitForOperation(created);
299 }
300 current = yield* waitUntilPresent(getByName(name), name);
301 }
302
303 return toAttrs(current, env.project, env.region);
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 yield* retryOnTransient(
308 Effect.gen(function* () {
309 const existing = yield* getByName(output.name);
310 if (existing === undefined) return;
311 const operation = yield* eventarc
312 .deleteProjectsLocationsChannelConnections({
313 name: output.name,
314 })
315 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
316 if (operation !== undefined) {
317 yield* waitForOperation(operation, { notFoundOk: true });
318 }
319 }),
320 ).pipe(
321 Effect.catchTag("GCP.OperationFailed", (error) =>
322 getByName(output.name).pipe(
323 Effect.flatMap((current) =>
324 current === undefined ? Effect.void : Effect.fail(error),
325 ),
326 ),
327 ),
328 );
329 yield* waitUntilGone(getByName(output.name), output.name);
330 }),
331 });