1import * as developerconnect from "@distilled.cloud/gcp/developerconnect_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 type { ServiceDirectoryConfig } from "./Connection.ts";
18import {
19 compact,
20 fieldMask,
21 fingerprint,
22 listAtLocation,
23 listLabeledPages,
24 normalizeLocation,
25 parentOf,
26 parseName,
27 replaceOnIdentity,
28 ResourceNotResolved,
29 sameBool,
30 toPhysicalId,
31 userAnnotations,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36} from "./internal.ts";
37
38export type CustomOAuthConfig = {
39 /**
40 * Client secret of the OAuth application. Input only — not returned
41 * on read.
42 */
43 clientSecret?: string;
44 /**
45 * Service Directory config for a private OAuth host.
46 */
47 serviceDirectoryConfig?: ServiceDirectoryConfig;
48 /**
49 * SCM provider (`GITHUB_ENTERPRISE`, `GITLAB_ENTERPRISE`,
50 * `BITBUCKET_DATA_CENTER`). Immutable — changing it replaces the
51 * connector.
52 */
53 scmProvider?:
54 | developerconnect.CustomOAuthConfigScmProviderEnum
55 | (string & {});
56 /**
57 * Host URI of the OAuth application. Immutable — changing it
58 * replaces the connector.
59 */
60 hostUri?: string;
61 /**
62 * OAuth client id.
63 */
64 clientId?: string;
65 /**
66 * SSL certificate used for requests to a private host.
67 */
68 sslCaCertificate?: string;
69 /**
70 * OAuth2 token request URL. Immutable — changing it replaces the
71 * connector.
72 */
73 tokenUri?: string;
74 /**
75 * Disable PKCE for this OAuth config. PKCE is enabled by default.
76 */
77 pkceDisabled?: boolean;
78 /**
79 * OAuth2 authorization server URL. Immutable — changing it replaces
80 * the connector.
81 */
82 authUri?: string;
83 /**
84 * Scopes requested during OAuth.
85 */
86 scopes?: string[];
87};
88
89export type ProviderOAuthConfig = {
90 /**
91 * Developer Connect provided OAuth system
92 * (`GITHUB`, `GITLAB`, `GOOGLE`, `SENTRY`, `ROVO`, `NEW_RELIC`,
93 * `DATASTAX`, `DYNATRACE`). Immutable — changing it replaces the
94 * connector.
95 */
96 systemProviderId?:
97 | developerconnect.ProviderOAuthConfigSystemProviderIdEnum
98 | (string & {});
99 /**
100 * User-selected OAuth scopes. Changing scopes deletes existing user
101 * records under the connector so users re-auth.
102 */
103 scopes?: string[];
104};
105
106export type ProxyConfig = {
107 /**
108 * Allow the git and HTTP proxies to act on behalf of users configured
109 * under this connector.
110 * @default false
111 */
112 enabled?: boolean;
113};
114
115export type AccountConnectorProps = {
116 /**
117 * Account connector id (the `{accountConnector}` segment of
118 * `projects/{project}/locations/{location}/accountConnectors/{accountConnector}`).
119 * If omitted, a unique RFC1035 name is generated from the stack,
120 * stage, and logical id. Immutable — changing it replaces the
121 * connector.
122 */
123 accountConnectorId?: string;
124 /**
125 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
126 * replaces the connector. `US-CENTRAL1` is accepted and normalized
127 * to `us-central1`.
128 * @default the stack's GCP region (`GCP.Region`, else the profile
129 * region, else `us-central1`)
130 */
131 location?: string;
132 /**
133 * Developer Connect provided OAuth. Mutually exclusive with
134 * `customOauthConfig`.
135 */
136 providerOauthConfig?: ProviderOAuthConfig;
137 /**
138 * Custom OAuth application. Mutually exclusive with
139 * `providerOauthConfig`. Changing immutable host fields replaces the
140 * connector.
141 */
142 customOauthConfig?: CustomOAuthConfig;
143 /**
144 * Git and HTTP proxy configuration.
145 */
146 proxyConfig?: ProxyConfig;
147 /**
148 * User labels. Alchemy ownership labels are merged in automatically.
149 */
150 labels?: Record<string, string>;
151 /**
152 * User annotations (AIP-148).
153 */
154 annotations?: Record<string, string>;
155};
156
157export type AccountConnector = Resource<
158 "GCP.DeveloperConnect.AccountConnector",
159 AccountConnectorProps,
160 {
161 /** Full resource name. */
162 name: string;
163 /** Account connector id (last path segment). */
164 accountConnectorId: string;
165 /** Project id. */
166 project: string;
167 /** Location id. */
168 location: string;
169 /** Developer Connect provided OAuth config, if any. */
170 providerOauthConfig: ProviderOAuthConfig | undefined;
171 /** Custom OAuth config, if any (secrets stripped). */
172 customOauthConfig: CustomOAuthConfig | undefined;
173 /** Proxy configuration. */
174 proxyConfig: ProxyConfig | undefined;
175 /** User labels (Alchemy ownership labels stripped). */
176 labels: Record<string, string>;
177 /** User annotations. */
178 annotations: Record<string, string>;
179 /** URL that starts the OAuth flow. */
180 oauthStartUri: string | undefined;
181 /** Server checksum of the resource. */
182 etag: string | undefined;
183 /** Server-generated resource uid. */
184 uid: string | undefined;
185 /** RFC3339 creation timestamp. */
186 createTime: string | undefined;
187 /** RFC3339 last-update timestamp. */
188 updateTime: string | undefined;
189 },
190 never,
191 Providers
192>;
193
194/**
195 * A Developer Connect account connector — the OAuth configuration that
196 * lets users connect their SCM accounts (GitHub, GitLab, Google, …) to
197 * Google Cloud.
198 *
199 * Changing `accountConnectorId`, `location`, `systemProviderId`, or
200 * immutable custom-OAuth host fields (`hostUri`, `tokenUri`, `authUri`,
201 * `scmProvider`) replaces the connector. Labels, annotations, scopes,
202 * and `proxyConfig` update in place.
203 *
204 * ### Creating an Account Connector
205 * **Example:** GitHub system provider
206 * ```typescript
207 * const github = yield* GCP.DeveloperConnect.AccountConnector("Github", {
208 * providerOauthConfig: {
209 * systemProviderId: "GITHUB",
210 * scopes: ["repo"],
211 * },
212 * });
213 * ```
214 *
215 * **Example:** Explicit id, labels, and proxy
216 * ```typescript
217 * const github = yield* GCP.DeveloperConnect.AccountConnector("Github", {
218 * accountConnectorId: "app-github",
219 * location: "us-central1",
220 * providerOauthConfig: {
221 * systemProviderId: "GITHUB",
222 * scopes: ["repo", "read:user"],
223 * },
224 * proxyConfig: { enabled: true },
225 * labels: { env: "prod" },
226 * });
227 * ```
228 *
229 * ### Updating an Account Connector
230 * **Example:** Labels and proxy
231 * ```typescript
232 * // Same logical id as before; only the changed props differ.
233 * const github = yield* GCP.DeveloperConnect.AccountConnector("Github", {
234 * accountConnectorId: "app-github",
235 * providerOauthConfig: {
236 * systemProviderId: "GITHUB",
237 * scopes: ["repo"],
238 * },
239 * labels: { env: "prod", team: "platform" },
240 * proxyConfig: { enabled: true },
241 * });
242 * ```
243 *
244 * @resource
245 * @category DeveloperConnect
246 */
247export const AccountConnector = Resource<AccountConnector>(
248 "GCP.DeveloperConnect.AccountConnector",
249);
250
251const resourceName = (
252 project: string,
253 location: string,
254 accountConnectorId: string,
255) =>
256 `projects/${project}/locations/${location}/accountConnectors/${accountConnectorId}`;
257
258const toServiceDirectory = (
259 config: ServiceDirectoryConfig | undefined,
260): developerconnect.ServiceDirectoryConfig | undefined => {
261 if (config === undefined) return undefined;
262 return compact({ service: config.service });
263};
264
265const toCustomOauth = (
266 config: CustomOAuthConfig | undefined,
267): developerconnect.CustomOAuthConfig | undefined => {
268 if (config === undefined) return undefined;
269 return compact({
270 clientSecret: config.clientSecret,
271 serviceDirectoryConfig: toServiceDirectory(config.serviceDirectoryConfig),
272 scmProvider: config.scmProvider,
273 hostUri: config.hostUri,
274 clientId: config.clientId,
275 sslCaCertificate: config.sslCaCertificate,
276 tokenUri: config.tokenUri,
277 pkceDisabled: config.pkceDisabled,
278 authUri: config.authUri,
279 scopes: config.scopes,
280 });
281};
282
283const toProviderOauth = (
284 config: ProviderOAuthConfig | undefined,
285): developerconnect.ProviderOAuthConfig | undefined => {
286 if (config === undefined) return undefined;
287 return compact({
288 systemProviderId: config.systemProviderId,
289 scopes: config.scopes,
290 });
291};
292
293const toProxy = (
294 config: ProxyConfig | undefined,
295): developerconnect.ProxyConfig | undefined => {
296 if (config === undefined) return undefined;
297 return compact({ enabled: config.enabled });
298};
299
300const fromCustomOauth = (
301 config: developerconnect.CustomOAuthConfig | undefined,
302): CustomOAuthConfig | undefined => {
303 if (config === undefined) return undefined;
304 return compact({
305 serviceDirectoryConfig: config.serviceDirectoryConfig
306 ? compact({ service: config.serviceDirectoryConfig.service })
307 : undefined,
308 scmProvider: config.scmProvider,
309 hostUri: config.hostUri,
310 clientId: config.clientId,
311 sslCaCertificate: config.sslCaCertificate,
312 tokenUri: config.tokenUri,
313 pkceDisabled: config.pkceDisabled,
314 authUri: config.authUri,
315 scopes: config.scopes,
316 });
317};
318
319const fromProviderOauth = (
320 config: developerconnect.ProviderOAuthConfig | undefined,
321): ProviderOAuthConfig | undefined => {
322 if (config === undefined) return undefined;
323 return compact({
324 systemProviderId: config.systemProviderId,
325 scopes: config.scopes,
326 });
327};
328
329const fromProxy = (
330 config: developerconnect.ProxyConfig | undefined,
331): ProxyConfig | undefined => {
332 if (config === undefined) return undefined;
333 return compact({ enabled: config.enabled === true });
334};
335
336const toAttrs = (
337 item: developerconnect.AccountConnector,
338 project: string,
339 region: string,
340) => {
341 const name = item.name ?? "";
342 const parsed = parseName(name, "accountConnectors", region);
343 return {
344 name,
345 accountConnectorId: parsed.id,
346 project: parsed.project || project,
347 location: parsed.location,
348 providerOauthConfig: fromProviderOauth(item.providerOauthConfig),
349 customOauthConfig: fromCustomOauth(item.customOauthConfig),
350 proxyConfig: fromProxy(item.proxyConfig),
351 labels: userLabels(item.labels),
352 annotations: userAnnotations(item.annotations),
353 oauthStartUri: item.oauthStartUri,
354 etag: item.etag,
355 uid: item.uid,
356 createTime: item.createTime,
357 updateTime: item.updateTime,
358 };
359};
360
361const getByName = (name: string) =>
362 developerconnect
363 .getProjectsLocationsAccountConnectors({ name })
364 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
365
366const listOwned = (project: string, region: string) =>
367 listAtLocation(project, region, (parent) =>
368 listLabeledPages(
369 developerconnect.listProjectsLocationsAccountConnectors.pages({
370 parent,
371 pageSize: 1000,
372 }),
373 (page) => page.accountConnectors,
374 (item) => item.labels,
375 ),
376 );
377
378const immutableCustomKey = (config: CustomOAuthConfig | undefined) =>
379 fingerprint({
380 scmProvider: config?.scmProvider,
381 hostUri: config?.hostUri,
382 tokenUri: config?.tokenUri,
383 authUri: config?.authUri,
384 });
385
386export const AccountConnectorProvider = () =>
387 Provider.succeed(AccountConnector, {
388 stables: [
389 "name",
390 "accountConnectorId",
391 "project",
392 "location",
393 "uid",
394 "createTime",
395 ],
396
397 diff: Effect.fn(function* ({ news, olds, output }) {
398 if (!isResolved(news)) return undefined;
399 const env = yield* GcpEnvironment.current;
400 const previousProvider =
401 olds?.providerOauthConfig?.systemProviderId ??
402 output?.providerOauthConfig?.systemProviderId;
403 const nextProvider = news.providerOauthConfig?.systemProviderId;
404 const previousCustom =
405 immutableCustomKey(olds?.customOauthConfig) ??
406 immutableCustomKey(output?.customOauthConfig);
407 const nextCustom = immutableCustomKey(news.customOauthConfig);
408 return replaceOnIdentity({
409 previousId: olds?.accountConnectorId ?? output?.accountConnectorId,
410 nextId:
411 news.accountConnectorId ??
412 olds?.accountConnectorId ??
413 output?.accountConnectorId,
414 previousLocation: normalizeLocation(
415 olds?.location ?? output?.location,
416 env.region,
417 ),
418 nextLocation: normalizeLocation(
419 news.location ?? olds?.location ?? output?.location,
420 env.region,
421 ),
422 extra:
423 (previousProvider !== undefined &&
424 nextProvider !== undefined &&
425 previousProvider !== nextProvider) ||
426 (previousCustom !== fingerprint(undefined) &&
427 nextCustom !== fingerprint(undefined) &&
428 previousCustom !== nextCustom),
429 });
430 }),
431
432 read: Effect.fn(function* ({ id, olds, output }) {
433 const env = yield* GcpEnvironment.current;
434 const accountConnectorId = yield* toPhysicalId(
435 id,
436 olds?.accountConnectorId,
437 output?.accountConnectorId,
438 "accountconnector",
439 );
440 const location = normalizeLocation(
441 olds?.location ?? output?.location,
442 env.region,
443 );
444 const name =
445 output?.name ?? resourceName(env.project, location, accountConnectorId);
446 const existing = yield* getByName(name);
447 if (existing === undefined) return undefined;
448 const attrs = toAttrs(existing, env.project, env.region);
449 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
450 ? attrs
451 : Unowned(attrs);
452 }),
453
454 list: () =>
455 Effect.gen(function* () {
456 const env = yield* GcpEnvironment.current;
457 const items = yield* listOwned(env.project, env.region);
458 return items.map((item) => toAttrs(item, env.project, env.region));
459 }),
460
461 reconcile: Effect.fn(function* ({ id, news, output }) {
462 const env = yield* GcpEnvironment.current;
463 const accountConnectorId = yield* toPhysicalId(
464 id,
465 news.accountConnectorId,
466 output?.accountConnectorId,
467 "accountconnector",
468 );
469 const location = normalizeLocation(
470 news.location ?? output?.location,
471 env.region,
472 );
473 const name = resourceName(env.project, location, accountConnectorId);
474 const desiredLabels = {
475 ...toLabels(news.labels),
476 ...(yield* createInternalLabels(id)),
477 };
478 const annotations = news.annotations;
479 const body = compact({
480 providerOauthConfig: toProviderOauth(news.providerOauthConfig),
481 customOauthConfig: toCustomOauth(news.customOauthConfig),
482 proxyConfig: toProxy(news.proxyConfig),
483 labels: desiredLabels,
484 annotations,
485 });
486
487 let current = yield* getByName(output?.name ?? name);
488
489 if (current === undefined) {
490 const created = yield* developerconnect
491 .createProjectsLocationsAccountConnectors({
492 parent: parentOf(env.project, location),
493 accountConnectorId,
494 body,
495 })
496 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
497 if (created !== undefined) {
498 yield* waitForOperation(created);
499 }
500 current = yield* waitUntilExists(getByName(name), name);
501 }
502
503 if (current === undefined) {
504 return yield* new ResourceNotResolved({ name });
505 }
506
507 const observedLabels = tagRecord(current.labels);
508 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
509 const mask = fieldMask([
510 (upsert.length > 0 || removed.length > 0) && "labels",
511 fingerprint(userAnnotations(current.annotations)) !==
512 fingerprint(userAnnotations(annotations)) && "annotations",
513 fingerprint(fromProviderOauth(current.providerOauthConfig)) !==
514 fingerprint(news.providerOauthConfig) && "providerOauthConfig",
515 fingerprint(fromCustomOauth(current.customOauthConfig)) !==
516 fingerprint({
517 ...news.customOauthConfig,
518 clientSecret: undefined,
519 }) && "customOauthConfig",
520 !sameBool(current.proxyConfig?.enabled, news.proxyConfig?.enabled) &&
521 "proxyConfig",
522 ]);
523
524 if (mask.length > 0) {
525 const operation = yield* developerconnect
526 .patchProjectsLocationsAccountConnectors({
527 name: current.name ?? name,
528 updateMask: mask,
529 body: compact({
530 etag: current.etag,
531 ...body,
532 }),
533 })
534 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
535 if (operation === undefined) {
536 const created = yield* developerconnect
537 .createProjectsLocationsAccountConnectors({
538 parent: parentOf(env.project, location),
539 accountConnectorId,
540 body,
541 })
542 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
543 if (created !== undefined) {
544 yield* waitForOperation(created);
545 }
546 } else {
547 yield* waitForOperation(operation);
548 }
549 current = yield* waitUntilExists(
550 getByName(current.name ?? name),
551 current.name ?? name,
552 );
553 }
554
555 return toAttrs(current, env.project, env.region);
556 }),
557
558 delete: Effect.fn(function* ({ output }) {
559 const operation = yield* developerconnect
560 .deleteProjectsLocationsAccountConnectors({
561 name: output.name,
562 force: true,
563 })
564 .pipe(
565 Effect.retry({
566 while: (error) => error._tag === "Conflict",
567 times: 8,
568 schedule: Schedule.spaced("2 seconds"),
569 }),
570 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
571 );
572 if (operation !== undefined) {
573 yield* waitForOperation(operation, { notFoundOk: true });
574 }
575 yield* waitUntilGone(getByName(output.name), output.name);
576 }),
577 });