1import * as cloudbuild from "@distilled.cloud/gcp/cloudbuild_v2";
2import * as Data from "effect/Data";
3import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.ts";
22import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
23
24const MAX_NAME_LENGTH = 63;
25
26export type OAuthCredential = {
27 /**
28 * Secret Manager resource containing the OAuth token that authorizes the
29 * Cloud Build GitHub App, as
30 * `projects/{project}/secrets/{secret}/versions/{version}`.
31 */
32 oauthTokenSecretVersion?: string;
33};
34
35export type GitHubConfig = {
36 /**
37 * OAuth credential of the account that authorized the Cloud Build GitHub
38 * App. Prefer a robot account. The token must be tied to the Cloud Build
39 * GitHub App.
40 */
41 authorizerCredential?: OAuthCredential;
42 /**
43 * GitHub App installation id.
44 */
45 appInstallationId?: string;
46};
47
48export type ServiceDirectoryConfig = {
49 /**
50 * Service Directory service used to reach a private host, as
51 * `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}`.
52 */
53 service?: string;
54};
55
56export type UserCredential = {
57 /**
58 * Secret Manager resource containing the user token, as
59 * `projects/{project}/secrets/{secret}/versions/{version}`.
60 */
61 userTokenSecretVersion?: string;
62};
63
64export type GitHubEnterpriseConfig = {
65 /**
66 * URI of the GitHub Enterprise host. Immutable — changing it replaces
67 * the connection.
68 */
69 hostUri?: string;
70 /**
71 * API key used to authenticate webhook events.
72 */
73 apiKey?: string;
74 /**
75 * Id of the GitHub App created from the manifest.
76 */
77 appId?: string;
78 /**
79 * URL-friendly name of the GitHub App.
80 */
81 appSlug?: string;
82 /**
83 * Secret Manager resource containing the GitHub App private key, as
84 * `projects/{project}/secrets/{secret}/versions/{version}`.
85 */
86 privateKeySecretVersion?: string;
87 /**
88 * Secret Manager resource containing the GitHub App webhook secret, as
89 * `projects/{project}/secrets/{secret}/versions/{version}`.
90 */
91 webhookSecretSecretVersion?: string;
92 /**
93 * Installation id of the GitHub App.
94 */
95 appInstallationId?: string;
96 /**
97 * Service Directory config for a private GitHub Enterprise host.
98 */
99 serviceDirectoryConfig?: ServiceDirectoryConfig;
100 /**
101 * SSL certificate used for requests to GitHub Enterprise.
102 */
103 sslCa?: string;
104};
105
106export type GitLabConfig = {
107 /**
108 * GitLab host URI. Defaults to `https://gitlab.com`. Immutable —
109 * changing it replaces the connection.
110 */
111 hostUri?: string;
112 /**
113 * Secret Manager resource containing the webhook secret, as
114 * `projects/{project}/secrets/{secret}/versions/{version}`. Immutable —
115 * changing it replaces the connection.
116 */
117 webhookSecretSecretVersion?: string;
118 /**
119 * GitLab personal access token with at least `read_api` scope.
120 */
121 readAuthorizerCredential?: UserCredential;
122 /**
123 * GitLab personal access token with `api` scope.
124 */
125 authorizerCredential?: UserCredential;
126 /**
127 * Service Directory config for a private GitLab Enterprise host.
128 */
129 serviceDirectoryConfig?: ServiceDirectoryConfig;
130 /**
131 * SSL certificate used for requests to GitLab Enterprise.
132 */
133 sslCa?: string;
134};
135
136export type BitbucketDataCenterConfig = {
137 /**
138 * URI of the Bitbucket Data Center instance. Immutable — changing it
139 * replaces the connection.
140 */
141 hostUri?: string;
142 /**
143 * Secret Manager resource containing the webhook secret, as
144 * `projects/{project}/secrets/{secret}/versions/{version}`. Immutable —
145 * changing it replaces the connection.
146 */
147 webhookSecretSecretVersion?: string;
148 /**
149 * HTTP access token with `REPO_READ` access.
150 */
151 readAuthorizerCredential?: UserCredential;
152 /**
153 * HTTP access token with `REPO_ADMIN` access.
154 */
155 authorizerCredential?: UserCredential;
156 /**
157 * Service Directory config for a private Bitbucket Data Center host.
158 */
159 serviceDirectoryConfig?: ServiceDirectoryConfig;
160 /**
161 * SSL certificate used for requests to Bitbucket Data Center.
162 */
163 sslCa?: string;
164};
165
166export type BitbucketCloudConfig = {
167 /**
168 * Bitbucket Cloud workspace id. Immutable — changing it replaces the
169 * connection.
170 */
171 workspace?: string;
172 /**
173 * Secret Manager resource containing the webhook secret, as
174 * `projects/{project}/secrets/{secret}/versions/{version}`. Immutable —
175 * changing it replaces the connection.
176 */
177 webhookSecretSecretVersion?: string;
178 /**
179 * Access token with `repository` access.
180 */
181 readAuthorizerCredential?: UserCredential;
182 /**
183 * Access token with `webhook`, `repository`, `repository:admin`, and
184 * `pullrequest` scope.
185 */
186 authorizerCredential?: UserCredential;
187};
188
189export type InstallationState = {
190 /** Current step of the installation process. */
191 stage?: string;
192 /** Message describing the next user action, if any. */
193 message?: string;
194 /** Link for the next user action, if any. */
195 actionUri?: string;
196};
197
198export type ConnectionProps = {
199 /**
200 * Connection id (the `{connection}` segment of
201 * `projects/{project}/locations/{location}/connections/{connection}`).
202 * If omitted, a unique name is generated from the stack, stage, and
203 * logical id. Must be unique per project and location. Immutable —
204 * changing it replaces the connection.
205 */
206 connectionId?: string;
207 /**
208 * Cloud Build location (`us-central1`, `us-east1`, …). Immutable —
209 * changing it replaces the connection. `US-CENTRAL1` is accepted and
210 * normalized to `us-central1`.
211 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
212 */
213 location?: string;
214 /**
215 * When true, repository APIs and webhook processing for this connection
216 * are disabled.
217 * @default false
218 */
219 disabled?: boolean;
220 /**
221 * User annotations. Cloud Build connections have no labels, so Alchemy
222 * ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored
223 * here for `list` / nuke.
224 */
225 annotations?: Record<string, string>;
226 /**
227 * Configuration for github.com. If no SCM config is set, Alchemy
228 * defaults to an empty GitHub config on create (installation stays
229 * pending until the Cloud Build GitHub App is authorized).
230 */
231 githubConfig?: GitHubConfig;
232 /**
233 * Configuration for a GitHub Enterprise host. Mutually exclusive with
234 * the other SCM configs. Changing `hostUri` replaces the connection.
235 */
236 githubEnterpriseConfig?: GitHubEnterpriseConfig;
237 /**
238 * Configuration for gitlab.com or GitLab Enterprise. Mutually exclusive
239 * with the other SCM configs. Changing `hostUri` or
240 * `webhookSecretSecretVersion` replaces the connection.
241 */
242 gitlabConfig?: GitLabConfig;
243 /**
244 * Configuration for Bitbucket Data Center. Mutually exclusive with the
245 * other SCM configs. Changing `hostUri` or
246 * `webhookSecretSecretVersion` replaces the connection.
247 */
248 bitbucketDataCenterConfig?: BitbucketDataCenterConfig;
249 /**
250 * Configuration for Bitbucket Cloud. Mutually exclusive with the other
251 * SCM configs. Changing `workspace` or `webhookSecretSecretVersion`
252 * replaces the connection.
253 */
254 bitbucketCloudConfig?: BitbucketCloudConfig;
255};
256
257export type Connection = Resource<
258 "GCP.CloudBuild.Connection",
259 ConnectionProps,
260 {
261 /** Full resource name `projects/{project}/locations/{location}/connections/{connection}`. */
262 name: string;
263 /** Connection id (last path segment). */
264 connectionId: string;
265 /** Project id. */
266 project: string;
267 /** Location id (`us-central1`, …). */
268 location: string;
269 /** Whether repository APIs and webhooks are disabled. */
270 disabled: boolean;
271 /** User annotations (Alchemy ownership keys stripped). */
272 annotations: Record<string, string>;
273 /** github.com configuration, if this is a GitHub connection. */
274 githubConfig: GitHubConfig | undefined;
275 /** GitHub Enterprise configuration, if any. */
276 githubEnterpriseConfig: GitHubEnterpriseConfig | undefined;
277 /** GitLab configuration, if any. */
278 gitlabConfig: GitLabConfig | undefined;
279 /** Bitbucket Data Center configuration, if any. */
280 bitbucketDataCenterConfig: BitbucketDataCenterConfig | undefined;
281 /** Bitbucket Cloud configuration, if any. */
282 bitbucketCloudConfig: BitbucketCloudConfig | undefined;
283 /** Installation progress for GitHub-based connections. */
284 installationState: InstallationState | undefined;
285 /** True while Cloud Build is applying the connection in the background. */
286 reconciling: boolean;
287 /** Server checksum of the resource. */
288 etag: string | undefined;
289 /** RFC3339 creation timestamp. */
290 createTime: string | undefined;
291 /** RFC3339 last-update timestamp. */
292 updateTime: string | undefined;
293 },
294 never,
295 Providers
296>;
297
298/**
299 * A Cloud Build v2 connection to a source-control host (GitHub, GitHub
300 * Enterprise, GitLab, Bitbucket Data Center, or Bitbucket Cloud).
301 *
302 * Changing `connectionId` or `location` replaces the connection. Switching
303 * SCM kinds, or changing immutable host fields (`hostUri`, `workspace`,
304 * `webhookSecretSecretVersion`), also replaces. `disabled` and
305 * `annotations` update in place.
306 *
307 * Cloud Build connections have no labels. Alchemy stores ownership in
308 * annotations (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) so
309 * `list` / `pnpm nuke:gcp` can find them.
310 *
311 * ### Creating a Connection
312 * **Example:** Generated GitHub connection
313 * ```typescript
314 * const github = yield* GCP.CloudBuild.Connection("Github", {
315 * githubConfig: {},
316 * });
317 * ```
318 *
319 * **Example:** Named GitHub connection with installation
320 * ```typescript
321 * const github = yield* GCP.CloudBuild.Connection("Github", {
322 * connectionId: "app-github",
323 * location: "us-central1",
324 * annotations: { env: "prod" },
325 * githubConfig: {
326 * appInstallationId: "123456",
327 * authorizerCredential: {
328 * oauthTokenSecretVersion:
329 * "projects/{project}/secrets/github-pat/versions/latest",
330 * },
331 * },
332 * });
333 * ```
334 *
335 * ### Disabling a Connection
336 * **Example:** Disable webhook processing
337 * ```typescript
338 * const github = yield* GCP.CloudBuild.Connection("Github", {
339 * connectionId: "app-github",
340 * githubConfig: {},
341 * disabled: true,
342 * });
343 * ```
344 *
345 * ### GitLab
346 * **Example:** GitLab.com connection
347 * ```typescript
348 * const gitlab = yield* GCP.CloudBuild.Connection("Gitlab", {
349 * gitlabConfig: {
350 * webhookSecretSecretVersion:
351 * "projects/{project}/secrets/gitlab-webhook/versions/latest",
352 * readAuthorizerCredential: {
353 * userTokenSecretVersion:
354 * "projects/{project}/secrets/gitlab-read/versions/latest",
355 * },
356 * authorizerCredential: {
357 * userTokenSecretVersion:
358 * "projects/{project}/secrets/gitlab-api/versions/latest",
359 * },
360 * },
361 * });
362 * ```
363 *
364 * @resource
365 * @category CloudBuild
366 */
367export const Connection = Resource<Connection>("GCP.CloudBuild.Connection");
368
369export class ConnectionNotResolved extends Data.TaggedError(
370 "GCP.CloudBuild.ConnectionNotResolved",
371)<{
372 name: string;
373}> {}
374
375export class ConnectionStillExists extends Data.TaggedError(
376 "GCP.CloudBuild.ConnectionStillExists",
377)<{
378 name: string;
379}> {}
380
381type ScmKind =
382 | "github"
383 | "githubEnterprise"
384 | "gitlab"
385 | "bitbucketDataCenter"
386 | "bitbucketCloud";
387
388const lastSegment = (value: string) => {
389 const trimmed = value.replace(/\/+$/, "");
390 const parts = trimmed.split("/");
391 return parts[parts.length - 1] || trimmed;
392};
393
394const normalizeLocation = (location: string | undefined, fallback: string) =>
395 lastSegment(location ?? fallback).toLowerCase();
396
397const resourceName = (
398 project: string,
399 location: string,
400 connectionId: string,
401) => `projects/${project}/locations/${location}/connections/${connectionId}`;
402
403const parentOf = (project: string, location: string) =>
404 `projects/${project}/locations/${location}`;
405
406const parseName = (name: string) => {
407 const parts = name.split("/").filter((part) => part.length > 0);
408 const connectionsAt = parts.lastIndexOf("connections");
409 const locationsAt = parts.lastIndexOf("locations");
410 const projectsAt = parts.lastIndexOf("projects");
411 return {
412 project:
413 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
414 location:
415 locationsAt >= 0 && parts[locationsAt + 1] ? parts[locationsAt + 1]! : "",
416 connectionId:
417 connectionsAt >= 0 && parts[connectionsAt + 1]
418 ? parts[connectionsAt + 1]!
419 : lastSegment(name),
420 };
421};
422
423const userAnnotations = (
424 annotations: Record<string, string | undefined> | null | undefined,
425): Record<string, string> => stripInternalLabels(tagRecord(annotations));
426
427const toId = (
428 id: string,
429 connectionId: string | undefined,
430 existing?: string,
431) =>
432 Effect.gen(function* () {
433 return (
434 connectionId ??
435 existing ??
436 (yield* createPhysicalName({
437 id,
438 maxLength: MAX_NAME_LENGTH,
439 lowercase: true,
440 }))
441 );
442 });
443
444const compact = <T extends Record<string, unknown>>(value: T): T =>
445 Object.fromEntries(
446 Object.entries(value).filter(([, item]) => item !== undefined),
447 ) as T;
448
449const toOAuthCredential = (
450 credential: OAuthCredential | undefined,
451): cloudbuild.OAuthCredential | undefined => {
452 if (credential === undefined) return undefined;
453 return compact({
454 oauthTokenSecretVersion: credential.oauthTokenSecretVersion,
455 });
456};
457
458const toUserCredential = (
459 credential: UserCredential | undefined,
460): cloudbuild.UserCredential | undefined => {
461 if (credential === undefined) return undefined;
462 return compact({
463 userTokenSecretVersion: credential.userTokenSecretVersion,
464 });
465};
466
467const toServiceDirectory = (
468 config: ServiceDirectoryConfig | undefined,
469): cloudbuild.GoogleDevtoolsCloudbuildV2ServiceDirectoryConfig | undefined => {
470 if (config === undefined) return undefined;
471 return compact({ service: config.service });
472};
473
474const toGithub = (
475 config: GitHubConfig | undefined,
476): cloudbuild.GitHubConfig | undefined => {
477 if (config === undefined) return undefined;
478 return compact({
479 authorizerCredential: toOAuthCredential(config.authorizerCredential),
480 appInstallationId: config.appInstallationId,
481 });
482};
483
484const toGithubEnterprise = (
485 config: GitHubEnterpriseConfig | undefined,
486): cloudbuild.GoogleDevtoolsCloudbuildV2GitHubEnterpriseConfig | undefined => {
487 if (config === undefined) return undefined;
488 return compact({
489 hostUri: config.hostUri,
490 apiKey: config.apiKey,
491 appId: config.appId,
492 appSlug: config.appSlug,
493 privateKeySecretVersion: config.privateKeySecretVersion,
494 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
495 appInstallationId: config.appInstallationId,
496 serviceDirectoryConfig: toServiceDirectory(config.serviceDirectoryConfig),
497 sslCa: config.sslCa,
498 });
499};
500
501const toGitlab = (
502 config: GitLabConfig | undefined,
503): cloudbuild.GoogleDevtoolsCloudbuildV2GitLabConfig | undefined => {
504 if (config === undefined) return undefined;
505 return compact({
506 hostUri: config.hostUri,
507 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
508 readAuthorizerCredential: toUserCredential(config.readAuthorizerCredential),
509 authorizerCredential: toUserCredential(config.authorizerCredential),
510 serviceDirectoryConfig: toServiceDirectory(config.serviceDirectoryConfig),
511 sslCa: config.sslCa,
512 });
513};
514
515const toBitbucketDataCenter = (
516 config: BitbucketDataCenterConfig | undefined,
517): cloudbuild.BitbucketDataCenterConfig | undefined => {
518 if (config === undefined) return undefined;
519 return compact({
520 hostUri: config.hostUri,
521 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
522 readAuthorizerCredential: toUserCredential(config.readAuthorizerCredential),
523 authorizerCredential: toUserCredential(config.authorizerCredential),
524 serviceDirectoryConfig: toServiceDirectory(config.serviceDirectoryConfig),
525 sslCa: config.sslCa,
526 });
527};
528
529const toBitbucketCloud = (
530 config: BitbucketCloudConfig | undefined,
531): cloudbuild.BitbucketCloudConfig | undefined => {
532 if (config === undefined) return undefined;
533 return compact({
534 workspace: config.workspace,
535 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
536 readAuthorizerCredential: toUserCredential(config.readAuthorizerCredential),
537 authorizerCredential: toUserCredential(config.authorizerCredential),
538 });
539};
540
541const fromGithub = (
542 config: cloudbuild.GitHubConfig | undefined,
543): GitHubConfig | undefined => {
544 if (config === undefined) return undefined;
545 return compact({
546 authorizerCredential: config.authorizerCredential
547 ? compact({
548 oauthTokenSecretVersion:
549 config.authorizerCredential.oauthTokenSecretVersion,
550 })
551 : undefined,
552 appInstallationId: config.appInstallationId,
553 });
554};
555
556const fromGithubEnterprise = (
557 config:
558 | cloudbuild.GoogleDevtoolsCloudbuildV2GitHubEnterpriseConfig
559 | undefined,
560): GitHubEnterpriseConfig | undefined => {
561 if (config === undefined) return undefined;
562 return compact({
563 hostUri: config.hostUri,
564 apiKey: config.apiKey,
565 appId: config.appId,
566 appSlug: config.appSlug,
567 privateKeySecretVersion: config.privateKeySecretVersion,
568 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
569 appInstallationId: config.appInstallationId,
570 serviceDirectoryConfig: config.serviceDirectoryConfig
571 ? compact({ service: config.serviceDirectoryConfig.service })
572 : undefined,
573 sslCa: config.sslCa,
574 });
575};
576
577const fromGitlab = (
578 config: cloudbuild.GoogleDevtoolsCloudbuildV2GitLabConfig | undefined,
579): GitLabConfig | undefined => {
580 if (config === undefined) return undefined;
581 return compact({
582 hostUri: config.hostUri,
583 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
584 readAuthorizerCredential: config.readAuthorizerCredential
585 ? compact({
586 userTokenSecretVersion:
587 config.readAuthorizerCredential.userTokenSecretVersion,
588 })
589 : undefined,
590 authorizerCredential: config.authorizerCredential
591 ? compact({
592 userTokenSecretVersion:
593 config.authorizerCredential.userTokenSecretVersion,
594 })
595 : undefined,
596 serviceDirectoryConfig: config.serviceDirectoryConfig
597 ? compact({ service: config.serviceDirectoryConfig.service })
598 : undefined,
599 sslCa: config.sslCa,
600 });
601};
602
603const fromBitbucketDataCenter = (
604 config: cloudbuild.BitbucketDataCenterConfig | undefined,
605): BitbucketDataCenterConfig | undefined => {
606 if (config === undefined) return undefined;
607 return compact({
608 hostUri: config.hostUri,
609 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
610 readAuthorizerCredential: config.readAuthorizerCredential
611 ? compact({
612 userTokenSecretVersion:
613 config.readAuthorizerCredential.userTokenSecretVersion,
614 })
615 : undefined,
616 authorizerCredential: config.authorizerCredential
617 ? compact({
618 userTokenSecretVersion:
619 config.authorizerCredential.userTokenSecretVersion,
620 })
621 : undefined,
622 serviceDirectoryConfig: config.serviceDirectoryConfig
623 ? compact({ service: config.serviceDirectoryConfig.service })
624 : undefined,
625 sslCa: config.sslCa,
626 });
627};
628
629const fromBitbucketCloud = (
630 config: cloudbuild.BitbucketCloudConfig | undefined,
631): BitbucketCloudConfig | undefined => {
632 if (config === undefined) return undefined;
633 return compact({
634 workspace: config.workspace,
635 webhookSecretSecretVersion: config.webhookSecretSecretVersion,
636 readAuthorizerCredential: config.readAuthorizerCredential
637 ? compact({
638 userTokenSecretVersion:
639 config.readAuthorizerCredential.userTokenSecretVersion,
640 })
641 : undefined,
642 authorizerCredential: config.authorizerCredential
643 ? compact({
644 userTokenSecretVersion:
645 config.authorizerCredential.userTokenSecretVersion,
646 })
647 : undefined,
648 });
649};
650
651const fromInstallationState = (
652 state: cloudbuild.InstallationState | undefined,
653): InstallationState | undefined => {
654 if (state === undefined) return undefined;
655 return compact({
656 stage: state.stage,
657 message: state.message,
658 actionUri: state.actionUri,
659 });
660};
661
662const scmKindOf = (value: {
663 githubConfig?: unknown;
664 githubEnterpriseConfig?: unknown;
665 gitlabConfig?: unknown;
666 bitbucketDataCenterConfig?: unknown;
667 bitbucketCloudConfig?: unknown;
668}): ScmKind | undefined => {
669 if (value.githubEnterpriseConfig !== undefined) return "githubEnterprise";
670 if (value.gitlabConfig !== undefined) return "gitlab";
671 if (value.bitbucketDataCenterConfig !== undefined)
672 return "bitbucketDataCenter";
673 if (value.bitbucketCloudConfig !== undefined) return "bitbucketCloud";
674 if (value.githubConfig !== undefined) return "github";
675 return undefined;
676};
677
678const observedScmKind = (
679 connection: cloudbuild.Connection,
680): ScmKind | undefined => {
681 if (connection.githubEnterpriseConfig !== undefined)
682 return "githubEnterprise";
683 if (connection.gitlabConfig !== undefined) return "gitlab";
684 if (connection.bitbucketDataCenterConfig !== undefined)
685 return "bitbucketDataCenter";
686 if (connection.bitbucketCloudConfig !== undefined) return "bitbucketCloud";
687 if (connection.githubConfig !== undefined) return "github";
688 return undefined;
689};
690
691const desiredScmKind = (news: ConnectionProps): ScmKind | undefined =>
692 scmKindOf(news);
693
694const toScmBody = (news: ConnectionProps): Partial<cloudbuild.Connection> => {
695 const kind = desiredScmKind(news) ?? "github";
696 switch (kind) {
697 case "githubEnterprise":
698 return {
699 githubEnterpriseConfig: toGithubEnterprise(news.githubEnterpriseConfig),
700 };
701 case "gitlab":
702 return { gitlabConfig: toGitlab(news.gitlabConfig) };
703 case "bitbucketDataCenter":
704 return {
705 bitbucketDataCenterConfig: toBitbucketDataCenter(
706 news.bitbucketDataCenterConfig,
707 ),
708 };
709 case "bitbucketCloud":
710 return {
711 bitbucketCloudConfig: toBitbucketCloud(news.bitbucketCloudConfig),
712 };
713 default:
714 return { githubConfig: toGithub(news.githubConfig ?? {}) };
715 }
716};
717
718const fieldChanged = (
719 desired: string | undefined,
720 observed: string | undefined,
721) => desired !== undefined && (desired ?? "") !== (observed ?? "");
722
723const githubChanged = (
724 desired: GitHubConfig | undefined,
725 observed: GitHubConfig | undefined,
726) => {
727 if (desired === undefined) return false;
728 return (
729 fieldChanged(desired.appInstallationId, observed?.appInstallationId) ||
730 fieldChanged(
731 desired.authorizerCredential?.oauthTokenSecretVersion,
732 observed?.authorizerCredential?.oauthTokenSecretVersion,
733 )
734 );
735};
736
737const githubEnterpriseChanged = (
738 desired: GitHubEnterpriseConfig | undefined,
739 observed: GitHubEnterpriseConfig | undefined,
740) => {
741 if (desired === undefined) return false;
742 return (
743 fieldChanged(desired.hostUri, observed?.hostUri) ||
744 fieldChanged(desired.apiKey, observed?.apiKey) ||
745 fieldChanged(desired.appId, observed?.appId) ||
746 fieldChanged(desired.appSlug, observed?.appSlug) ||
747 fieldChanged(
748 desired.privateKeySecretVersion,
749 observed?.privateKeySecretVersion,
750 ) ||
751 fieldChanged(
752 desired.webhookSecretSecretVersion,
753 observed?.webhookSecretSecretVersion,
754 ) ||
755 fieldChanged(desired.appInstallationId, observed?.appInstallationId) ||
756 fieldChanged(
757 desired.serviceDirectoryConfig?.service,
758 observed?.serviceDirectoryConfig?.service,
759 ) ||
760 fieldChanged(desired.sslCa, observed?.sslCa)
761 );
762};
763
764const gitlabChanged = (
765 desired: GitLabConfig | undefined,
766 observed: GitLabConfig | undefined,
767) => {
768 if (desired === undefined) return false;
769 return (
770 fieldChanged(desired.hostUri, observed?.hostUri) ||
771 fieldChanged(
772 desired.webhookSecretSecretVersion,
773 observed?.webhookSecretSecretVersion,
774 ) ||
775 fieldChanged(
776 desired.readAuthorizerCredential?.userTokenSecretVersion,
777 observed?.readAuthorizerCredential?.userTokenSecretVersion,
778 ) ||
779 fieldChanged(
780 desired.authorizerCredential?.userTokenSecretVersion,
781 observed?.authorizerCredential?.userTokenSecretVersion,
782 ) ||
783 fieldChanged(
784 desired.serviceDirectoryConfig?.service,
785 observed?.serviceDirectoryConfig?.service,
786 ) ||
787 fieldChanged(desired.sslCa, observed?.sslCa)
788 );
789};
790
791const bitbucketDataCenterChanged = (
792 desired: BitbucketDataCenterConfig | undefined,
793 observed: BitbucketDataCenterConfig | undefined,
794) => {
795 if (desired === undefined) return false;
796 return (
797 fieldChanged(desired.hostUri, observed?.hostUri) ||
798 fieldChanged(
799 desired.webhookSecretSecretVersion,
800 observed?.webhookSecretSecretVersion,
801 ) ||
802 fieldChanged(
803 desired.readAuthorizerCredential?.userTokenSecretVersion,
804 observed?.readAuthorizerCredential?.userTokenSecretVersion,
805 ) ||
806 fieldChanged(
807 desired.authorizerCredential?.userTokenSecretVersion,
808 observed?.authorizerCredential?.userTokenSecretVersion,
809 ) ||
810 fieldChanged(
811 desired.serviceDirectoryConfig?.service,
812 observed?.serviceDirectoryConfig?.service,
813 ) ||
814 fieldChanged(desired.sslCa, observed?.sslCa)
815 );
816};
817
818const bitbucketCloudChanged = (
819 desired: BitbucketCloudConfig | undefined,
820 observed: BitbucketCloudConfig | undefined,
821) => {
822 if (desired === undefined) return false;
823 return (
824 fieldChanged(desired.workspace, observed?.workspace) ||
825 fieldChanged(
826 desired.webhookSecretSecretVersion,
827 observed?.webhookSecretSecretVersion,
828 ) ||
829 fieldChanged(
830 desired.readAuthorizerCredential?.userTokenSecretVersion,
831 observed?.readAuthorizerCredential?.userTokenSecretVersion,
832 ) ||
833 fieldChanged(
834 desired.authorizerCredential?.userTokenSecretVersion,
835 observed?.authorizerCredential?.userTokenSecretVersion,
836 )
837 );
838};
839
840const immutableHostOf = (value: {
841 githubEnterpriseConfig?: { hostUri?: string };
842 gitlabConfig?: { hostUri?: string };
843 bitbucketDataCenterConfig?: { hostUri?: string };
844 bitbucketCloudConfig?: { workspace?: string };
845}) =>
846 value.githubEnterpriseConfig?.hostUri ??
847 value.gitlabConfig?.hostUri ??
848 value.bitbucketDataCenterConfig?.hostUri ??
849 value.bitbucketCloudConfig?.workspace;
850
851const immutableWebhookOf = (value: {
852 githubEnterpriseConfig?: { webhookSecretSecretVersion?: string };
853 gitlabConfig?: { webhookSecretSecretVersion?: string };
854 bitbucketDataCenterConfig?: { webhookSecretSecretVersion?: string };
855 bitbucketCloudConfig?: { webhookSecretSecretVersion?: string };
856}) =>
857 value.githubEnterpriseConfig?.webhookSecretSecretVersion ??
858 value.gitlabConfig?.webhookSecretSecretVersion ??
859 value.bitbucketDataCenterConfig?.webhookSecretSecretVersion ??
860 value.bitbucketCloudConfig?.webhookSecretSecretVersion;
861
862const toAttrs = (connection: cloudbuild.Connection, project: string) => {
863 const name = connection.name ?? "";
864 const parsed = parseName(name);
865 return {
866 name,
867 connectionId: parsed.connectionId,
868 project: parsed.project || project,
869 location: parsed.location,
870 disabled: connection.disabled === true,
871 annotations: userAnnotations(connection.annotations),
872 githubConfig: fromGithub(connection.githubConfig),
873 githubEnterpriseConfig: fromGithubEnterprise(
874 connection.githubEnterpriseConfig,
875 ),
876 gitlabConfig: fromGitlab(connection.gitlabConfig),
877 bitbucketDataCenterConfig: fromBitbucketDataCenter(
878 connection.bitbucketDataCenterConfig,
879 ),
880 bitbucketCloudConfig: fromBitbucketCloud(connection.bitbucketCloudConfig),
881 installationState: fromInstallationState(connection.installationState),
882 reconciling: connection.reconciling === true,
883 etag: connection.etag,
884 createTime: connection.createTime,
885 updateTime: connection.updateTime,
886 };
887};
888
889const getByName = (name: string) =>
890 cloudbuild
891 .getProjectsLocationsConnections({ name })
892 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
893
894/**
895 * Wait on a Cloud Build connections long-running operation (seconds to a minute).
896 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
897 * when `notFoundOk` (deletes). Returns the final operation.
898 */
899const waitForOperation = (
900 operation: cloudbuild.Operation,
901 options?: { notFoundOk?: boolean },
902) =>
903 Effect.suspend(() => {
904 let latest = operation;
905 return waitForLongRunningOperation(
906 operation,
907 (name) => {
908 const get = cloudbuild.getProjectsLocationsOperations({ name }).pipe(
909 Effect.tap((current) =>
910 Effect.sync(() => {
911 latest = current;
912 }),
913 ),
914 );
915 const observe: Effect.Effect<
916 cloudbuild.Operation,
917 cloudbuild.GetProjectsLocationsOperationsError,
918 GcpOpContext
919 > =
920 options?.notFoundOk === true
921 ? get.pipe(
922 Effect.catchTag("NotFound", () =>
923 Effect.succeed<cloudbuild.Operation>({ name, done: true }),
924 ),
925 )
926 : get.pipe(
927 // A just-returned operation can briefly 404 on read.
928 Effect.retry({
929 while: (error) => error._tag === "NotFound",
930 times: 5,
931 schedule: Schedule.exponential("250 millis"),
932 }),
933 );
934 return observe;
935 },
936 { budget: "5 minutes" },
937 ).pipe(
938 Effect.map(() => latest),
939 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
940 Effect.catchTag("GCP.OperationFailed", (error) =>
941 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
942 ? Effect.succeed(latest)
943 : Effect.fail(error),
944 ),
945 );
946 });
947
948const waitUntilExists = (name: string) =>
949 getByName(name).pipe(
950 Effect.flatMap((connection) =>
951 connection
952 ? Effect.succeed(connection)
953 : Effect.fail(new ConnectionNotResolved({ name })),
954 ),
955 Effect.retry({
956 while: (error) => error._tag === "GCP.CloudBuild.ConnectionNotResolved",
957 times: 8,
958 schedule: Schedule.spaced("1 second"),
959 }),
960 );
961
962const waitUntilGone = (name: string) =>
963 getByName(name).pipe(
964 Effect.flatMap((connection) =>
965 connection === undefined
966 ? Effect.void
967 : Effect.fail(new ConnectionStillExists({ name })),
968 ),
969 Effect.retry({
970 while: (error) => error._tag === "GCP.CloudBuild.ConnectionStillExists",
971 times: 10,
972 schedule: Schedule.spaced("1 second"),
973 }),
974 );
975
976export const ConnectionProvider = () =>
977 Provider.succeed(Connection, {
978 stables: ["name", "connectionId", "location", "project", "createTime"],
979
980 diff: Effect.fn(function* ({ news, olds, output }) {
981 const env = yield* GcpEnvironment.current;
982 if (!isResolved(news)) return undefined;
983 const previousId = olds?.connectionId ?? output?.connectionId;
984 const nextId = news.connectionId ?? previousId;
985 const idChanged =
986 previousId !== undefined &&
987 nextId !== undefined &&
988 nextId !== previousId;
989
990 const previousLocation = normalizeLocation(
991 olds?.location ?? output?.location,
992 env.region,
993 );
994 const nextLocation = normalizeLocation(
995 news.location ?? output?.location,
996 env.region,
997 );
998 const locationChanged = previousLocation !== nextLocation;
999
1000 const previousKind =
1001 desiredScmKind(olds ?? {}) ??
1002 (output === undefined ? undefined : scmKindOf(output));
1003 const nextKind = desiredScmKind(news);
1004 const kindChanged =
1005 previousKind !== undefined &&
1006 nextKind !== undefined &&
1007 nextKind !== previousKind;
1008
1009 const previousHost =
1010 immutableHostOf(olds ?? {}) ?? immutableHostOf(output ?? {});
1011 const nextHost = immutableHostOf(news);
1012 const hostChanged =
1013 previousHost !== undefined &&
1014 nextHost !== undefined &&
1015 nextHost !== previousHost;
1016
1017 const previousWebhook =
1018 immutableWebhookOf(olds ?? {}) ?? immutableWebhookOf(output ?? {});
1019 const nextWebhook = immutableWebhookOf(news);
1020 const webhookChanged =
1021 previousWebhook !== undefined &&
1022 nextWebhook !== undefined &&
1023 nextWebhook !== previousWebhook;
1024
1025 if (
1026 idChanged ||
1027 locationChanged ||
1028 kindChanged ||
1029 hostChanged ||
1030 webhookChanged
1031 ) {
1032 return { action: "replace" as const, deleteFirst: false };
1033 }
1034 return undefined;
1035 }),
1036
1037 read: Effect.fn(function* ({ id, olds, output }) {
1038 const env = yield* GcpEnvironment.current;
1039 const connectionId = yield* toId(
1040 id,
1041 olds?.connectionId,
1042 output?.connectionId,
1043 );
1044 const location = normalizeLocation(
1045 olds?.location ?? output?.location,
1046 env.region,
1047 );
1048 const name =
1049 output?.name ?? resourceName(env.project, location, connectionId);
1050 const existing = yield* getByName(name);
1051 if (existing === undefined) return undefined;
1052 const attrs = toAttrs(existing, env.project);
1053 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
1054 ? attrs
1055 : Unowned(attrs);
1056 }),
1057
1058 list: () =>
1059 Effect.gen(function* () {
1060 const env = yield* GcpEnvironment.current;
1061 return yield* cloudbuild.listProjectsLocationsConnections
1062 .pages({
1063 parent: `projects/${env.project}/locations/-`,
1064 pageSize: 1000,
1065 returnPartialSuccess: true,
1066 })
1067 .pipe(
1068 Stream.flatMap((page) =>
1069 Stream.fromIterable(page.connections ?? []),
1070 ),
1071 Stream.filter((connection) =>
1072 Object.keys(connection.annotations ?? {}).some((key) =>
1073 key.startsWith("alchemy-"),
1074 ),
1075 ),
1076 Stream.map((connection) => toAttrs(connection, env.project)),
1077 Stream.runCollect,
1078 Effect.map((chunk) => Array.from(chunk)),
1079 Effect.catchTag("NotFound", () => Effect.succeed([])),
1080 );
1081 }),
1082
1083 reconcile: Effect.fn(function* ({ id, news, output }) {
1084 const env = yield* GcpEnvironment.current;
1085 const connectionId = yield* toId(
1086 id,
1087 news.connectionId,
1088 output?.connectionId,
1089 );
1090 const location = normalizeLocation(
1091 news.location ?? output?.location,
1092 env.region,
1093 );
1094 const name = resourceName(env.project, location, connectionId);
1095 const parent = parentOf(env.project, location);
1096 const desiredAnnotations = {
1097 ...toLabels(news.annotations),
1098 ...(yield* createInternalLabels(id)),
1099 };
1100 const desiredDisabled = news.disabled === true;
1101 const scmBody = toScmBody(news);
1102
1103 let current = yield* getByName(name);
1104
1105 if (current === undefined) {
1106 const created = yield* cloudbuild
1107 .createProjectsLocationsConnections({
1108 parent,
1109 connectionId,
1110 body: compact({
1111 ...scmBody,
1112 disabled: desiredDisabled ? true : undefined,
1113 annotations: desiredAnnotations,
1114 }),
1115 })
1116 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
1117 if (created !== undefined) {
1118 yield* waitForOperation(created);
1119 }
1120 current = yield* waitUntilExists(name);
1121 }
1122
1123 if (current === undefined) {
1124 return yield* new ConnectionNotResolved({ name });
1125 }
1126
1127 const observedAnnotations = tagRecord(current.annotations);
1128 const { upsert, removed } = diffLabels(
1129 observedAnnotations,
1130 desiredAnnotations,
1131 );
1132 const annotationsChanged = upsert.length > 0 || removed.length > 0;
1133 const disabledChanged = (current.disabled === true) !== desiredDisabled;
1134 const observedKind = observedScmKind(current);
1135 const nextKind = desiredScmKind(news);
1136 const githubConfigChanged =
1137 nextKind === "github" &&
1138 githubChanged(news.githubConfig, fromGithub(current.githubConfig));
1139 const githubEnterpriseConfigChanged =
1140 nextKind === "githubEnterprise" &&
1141 githubEnterpriseChanged(
1142 news.githubEnterpriseConfig,
1143 fromGithubEnterprise(current.githubEnterpriseConfig),
1144 );
1145 const gitlabConfigChanged =
1146 nextKind === "gitlab" &&
1147 gitlabChanged(news.gitlabConfig, fromGitlab(current.gitlabConfig));
1148 const bitbucketDataCenterConfigChanged =
1149 nextKind === "bitbucketDataCenter" &&
1150 bitbucketDataCenterChanged(
1151 news.bitbucketDataCenterConfig,
1152 fromBitbucketDataCenter(current.bitbucketDataCenterConfig),
1153 );
1154 const bitbucketCloudConfigChanged =
1155 nextKind === "bitbucketCloud" &&
1156 bitbucketCloudChanged(
1157 news.bitbucketCloudConfig,
1158 fromBitbucketCloud(current.bitbucketCloudConfig),
1159 );
1160 const scmChanged =
1161 (nextKind !== undefined &&
1162 observedKind !== undefined &&
1163 nextKind !== observedKind) ||
1164 githubConfigChanged ||
1165 githubEnterpriseConfigChanged ||
1166 gitlabConfigChanged ||
1167 bitbucketDataCenterConfigChanged ||
1168 bitbucketCloudConfigChanged;
1169
1170 const updateMask = [
1171 annotationsChanged ? "annotations" : undefined,
1172 disabledChanged ? "disabled" : undefined,
1173 scmChanged && (nextKind ?? observedKind) === "github"
1174 ? "githubConfig"
1175 : undefined,
1176 scmChanged && (nextKind ?? observedKind) === "githubEnterprise"
1177 ? "githubEnterpriseConfig"
1178 : undefined,
1179 scmChanged && (nextKind ?? observedKind) === "gitlab"
1180 ? "gitlabConfig"
1181 : undefined,
1182 scmChanged && (nextKind ?? observedKind) === "bitbucketDataCenter"
1183 ? "bitbucketDataCenterConfig"
1184 : undefined,
1185 scmChanged && (nextKind ?? observedKind) === "bitbucketCloud"
1186 ? "bitbucketCloudConfig"
1187 : undefined,
1188 ].filter((field): field is string => field !== undefined);
1189
1190 if (updateMask.length > 0) {
1191 const body = compact({
1192 ...(scmChanged ? scmBody : {}),
1193 disabled: desiredDisabled,
1194 annotations: desiredAnnotations,
1195 });
1196 const operation = yield* cloudbuild
1197 .patchProjectsLocationsConnections({
1198 name,
1199 updateMask: updateMask.join(","),
1200 body,
1201 })
1202 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
1203 if (operation === undefined) {
1204 const created = yield* cloudbuild
1205 .createProjectsLocationsConnections({
1206 parent,
1207 connectionId,
1208 body: compact({
1209 ...scmBody,
1210 disabled: desiredDisabled ? true : undefined,
1211 annotations: desiredAnnotations,
1212 }),
1213 })
1214 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
1215 if (created !== undefined) {
1216 yield* waitForOperation(created);
1217 }
1218 } else {
1219 yield* waitForOperation(operation);
1220 }
1221 current = yield* waitUntilExists(name);
1222 }
1223
1224 if (current === undefined) {
1225 return yield* new ConnectionNotResolved({ name });
1226 }
1227
1228 return toAttrs(current, env.project);
1229 }),
1230
1231 delete: Effect.fn(function* ({ output }) {
1232 const operation = yield* cloudbuild
1233 .deleteProjectsLocationsConnections({ name: output.name })
1234 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
1235 if (operation !== undefined) {
1236 yield* waitForOperation(operation, { notFoundOk: true });
1237 }
1238 yield* waitUntilGone(output.name);
1239 }),
1240 });