@microagi/alchemy-gcp 0.2.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +165 -0
  2. package/README.md +54 -2
  3. package/lib/ArtifactRegistry/Operations.d.ts +47 -0
  4. package/lib/ArtifactRegistry/Operations.d.ts.map +1 -0
  5. package/lib/ArtifactRegistry/Operations.js +47 -0
  6. package/lib/ArtifactRegistry/Operations.js.map +1 -0
  7. package/lib/ArtifactRegistry/Repository.d.ts +263 -0
  8. package/lib/ArtifactRegistry/Repository.d.ts.map +1 -0
  9. package/lib/ArtifactRegistry/Repository.js +257 -0
  10. package/lib/ArtifactRegistry/Repository.js.map +1 -0
  11. package/lib/ArtifactRegistry/Types.d.ts +32 -0
  12. package/lib/ArtifactRegistry/Types.d.ts.map +1 -0
  13. package/lib/ArtifactRegistry/Types.js +12 -0
  14. package/lib/ArtifactRegistry/Types.js.map +1 -0
  15. package/lib/ArtifactRegistry/Validation.d.ts +43 -0
  16. package/lib/ArtifactRegistry/Validation.d.ts.map +1 -0
  17. package/lib/ArtifactRegistry/Validation.js +95 -0
  18. package/lib/ArtifactRegistry/Validation.js.map +1 -0
  19. package/lib/ArtifactRegistry/index.d.ts +4 -0
  20. package/lib/ArtifactRegistry/index.d.ts.map +1 -0
  21. package/lib/ArtifactRegistry/index.js +2 -0
  22. package/lib/ArtifactRegistry/index.js.map +1 -0
  23. package/lib/Providers.d.ts.map +1 -1
  24. package/lib/Providers.js +13 -1
  25. package/lib/Providers.js.map +1 -1
  26. package/lib/Run/IamMember.d.ts +70 -0
  27. package/lib/Run/IamMember.d.ts.map +1 -0
  28. package/lib/Run/IamMember.js +65 -0
  29. package/lib/Run/IamMember.js.map +1 -0
  30. package/lib/Run/IamSync.d.ts +80 -0
  31. package/lib/Run/IamSync.d.ts.map +1 -0
  32. package/lib/Run/IamSync.js +73 -0
  33. package/lib/Run/IamSync.js.map +1 -0
  34. package/lib/Run/Job.d.ts +167 -0
  35. package/lib/Run/Job.d.ts.map +1 -0
  36. package/lib/Run/Job.js +199 -0
  37. package/lib/Run/Job.js.map +1 -0
  38. package/lib/Run/Operations.d.ts +47 -0
  39. package/lib/Run/Operations.d.ts.map +1 -0
  40. package/lib/Run/Operations.js +46 -0
  41. package/lib/Run/Operations.js.map +1 -0
  42. package/lib/Run/Service.d.ts +193 -0
  43. package/lib/Run/Service.d.ts.map +1 -0
  44. package/lib/Run/Service.js +247 -0
  45. package/lib/Run/Service.js.map +1 -0
  46. package/lib/Run/Validation.d.ts +54 -0
  47. package/lib/Run/Validation.d.ts.map +1 -0
  48. package/lib/Run/Validation.js +129 -0
  49. package/lib/Run/Validation.js.map +1 -0
  50. package/lib/Run/index.d.ts +51 -0
  51. package/lib/Run/index.d.ts.map +1 -0
  52. package/lib/Run/index.js +4 -0
  53. package/lib/Run/index.js.map +1 -0
  54. package/lib/Sqladmin/Database.d.ts +82 -0
  55. package/lib/Sqladmin/Database.d.ts.map +1 -0
  56. package/lib/Sqladmin/Database.js +87 -0
  57. package/lib/Sqladmin/Database.js.map +1 -0
  58. package/lib/Sqladmin/Instance.d.ts +234 -0
  59. package/lib/Sqladmin/Instance.d.ts.map +1 -0
  60. package/lib/Sqladmin/Instance.js +251 -0
  61. package/lib/Sqladmin/Instance.js.map +1 -0
  62. package/lib/Sqladmin/Operations.d.ts +41 -0
  63. package/lib/Sqladmin/Operations.d.ts.map +1 -0
  64. package/lib/Sqladmin/Operations.js +42 -0
  65. package/lib/Sqladmin/Operations.js.map +1 -0
  66. package/lib/Sqladmin/Types.d.ts +82 -0
  67. package/lib/Sqladmin/Types.d.ts.map +1 -0
  68. package/lib/Sqladmin/Types.js +28 -0
  69. package/lib/Sqladmin/Types.js.map +1 -0
  70. package/lib/Sqladmin/User.d.ts +110 -0
  71. package/lib/Sqladmin/User.d.ts.map +1 -0
  72. package/lib/Sqladmin/User.js +156 -0
  73. package/lib/Sqladmin/User.js.map +1 -0
  74. package/lib/Sqladmin/Validation.d.ts +60 -0
  75. package/lib/Sqladmin/Validation.d.ts.map +1 -0
  76. package/lib/Sqladmin/Validation.js +125 -0
  77. package/lib/Sqladmin/Validation.js.map +1 -0
  78. package/lib/Sqladmin/index.d.ts +9 -0
  79. package/lib/Sqladmin/index.d.ts.map +1 -0
  80. package/lib/Sqladmin/index.js +5 -0
  81. package/lib/Sqladmin/index.js.map +1 -0
  82. package/lib/index.d.ts +3 -0
  83. package/lib/index.d.ts.map +1 -1
  84. package/lib/index.js +3 -0
  85. package/lib/index.js.map +1 -1
  86. package/package.json +5 -4
  87. package/src/ArtifactRegistry/Operations.ts +79 -0
  88. package/src/ArtifactRegistry/Repository.ts +624 -0
  89. package/src/ArtifactRegistry/Types.ts +49 -0
  90. package/src/ArtifactRegistry/Validation.ts +112 -0
  91. package/src/ArtifactRegistry/index.ts +10 -0
  92. package/src/Providers.ts +21 -0
  93. package/src/Run/IamMember.ts +79 -0
  94. package/src/Run/IamSync.ts +134 -0
  95. package/src/Run/Job.ts +453 -0
  96. package/src/Run/Operations.ts +77 -0
  97. package/src/Run/Service.ts +541 -0
  98. package/src/Run/Validation.ts +153 -0
  99. package/src/Run/index.ts +52 -0
  100. package/src/Sqladmin/Database.ts +209 -0
  101. package/src/Sqladmin/Instance.ts +620 -0
  102. package/src/Sqladmin/Operations.ts +73 -0
  103. package/src/Sqladmin/Types.ts +101 -0
  104. package/src/Sqladmin/User.ts +333 -0
  105. package/src/Sqladmin/Validation.ts +148 -0
  106. package/src/Sqladmin/index.ts +19 -0
  107. package/src/index.ts +3 -0
@@ -0,0 +1,112 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as Effect from "effect/Effect";
3
+ import type { ArtifactRegistryRepositoryProps } from "./Repository.ts";
4
+
5
+ /**
6
+ * Plan-time validation for Artifact Registry resource props. Failing
7
+ * fast with a typed `ConfigError` is friendlier than letting the user
8
+ * wait for the API to reject the request, and unlike server-side
9
+ * errors these surface before any state mutation happens.
10
+ *
11
+ * @see https://cloud.google.com/artifact-registry/docs/reference/rest/v1/projects.locations.repositories
12
+ */
13
+
14
+ /**
15
+ * Repository ID regex per
16
+ * <https://cloud.google.com/artifact-registry/docs/repositories/create-repos#create>:
17
+ * lowercase letters, digits and hyphens; must start with a letter or
18
+ * digit; 1–63 chars total. The REST API documents up to 63; we cap at
19
+ * 63 to match.
20
+ *
21
+ * @internal
22
+ */
23
+ const REPOSITORY_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
24
+
25
+ /**
26
+ * Validate an Artifact Registry repository ID.
27
+ *
28
+ * @see https://cloud.google.com/artifact-registry/docs/repositories/create-repos#create
29
+ */
30
+ export const validateRepositoryName = (
31
+ name: string,
32
+ ): Effect.Effect<void, ConfigError> => {
33
+ if (REPOSITORY_NAME_RE.test(name)) return Effect.void;
34
+ return Effect.fail(
35
+ new ConfigError({
36
+ message: `Artifact Registry repository name ${JSON.stringify(name)} is invalid: must match /^[a-z0-9][a-z0-9-]{0,62}$/ (1–63 lowercase letters/digits/hyphens, starting with a letter or digit).`,
37
+ }),
38
+ );
39
+ };
40
+
41
+ /**
42
+ * Validate the `mode` ↔ `remoteRepositoryConfig` invariant. Artifact
43
+ * Registry rejects (with an opaque 400) any combination where:
44
+ *
45
+ * - `mode === "REMOTE_REPOSITORY"` but no `remoteRepositoryConfig`
46
+ * is supplied — the server has no upstream to point at.
47
+ * - `mode === "STANDARD_REPOSITORY"` but a `remoteRepositoryConfig`
48
+ * IS supplied — standard repositories don't have an upstream.
49
+ *
50
+ * `VIRTUAL_REPOSITORY` is not validated here; pass-through for now.
51
+ *
52
+ * @see https://cloud.google.com/artifact-registry/docs/repositories/remote-repo
53
+ */
54
+ export const validateModeConfig = (
55
+ props: ArtifactRegistryRepositoryProps,
56
+ ): Effect.Effect<void, ConfigError> => {
57
+ const mode = props.mode ?? "STANDARD_REPOSITORY";
58
+ if (mode === "REMOTE_REPOSITORY") {
59
+ const docker = props.remoteRepositoryConfig?.dockerRepository;
60
+ if (!docker) {
61
+ return Effect.fail(
62
+ new ConfigError({
63
+ message: `Artifact Registry repository has mode "REMOTE_REPOSITORY" but no \`remoteRepositoryConfig.dockerRepository\` was provided. Pass either \`{ publicRepository: "DOCKER_HUB" }\` or \`{ customRepository: { uri: "https://nvcr.io" } }\`.`,
64
+ }),
65
+ );
66
+ }
67
+ return Effect.void;
68
+ }
69
+ if (mode === "STANDARD_REPOSITORY" && props.remoteRepositoryConfig) {
70
+ return Effect.fail(
71
+ new ConfigError({
72
+ message: `Artifact Registry repository has mode "STANDARD_REPOSITORY" but \`remoteRepositoryConfig\` was supplied. Drop \`remoteRepositoryConfig\` or set \`mode: "REMOTE_REPOSITORY"\`.`,
73
+ }),
74
+ );
75
+ }
76
+ return Effect.void;
77
+ };
78
+
79
+ /**
80
+ * Reshape a `BadRequest` from an Artifact Registry create/patch/delete
81
+ * into a `ConfigError` with a remediation hint for the top failure
82
+ * modes:
83
+ *
84
+ * 1. **Upstream validation failure** — `disableUpstreamValidation` is
85
+ * `false` (default) and AR's pre-flight HEAD request against the
86
+ * remote upstream failed. Surface the underlying message; the user
87
+ * can retry with `disableUpstreamValidation: true` if the upstream
88
+ * is intentionally gated.
89
+ * 2. **KMS key not accessible** — the AR service agent does not have
90
+ * `cloudkms.cryptoKeyEncrypterDecrypter` on the supplied key.
91
+ *
92
+ * Anything else propagates verbatim — the underlying GCP message is
93
+ * usually clear enough.
94
+ */
95
+ export const reshapeBadRequest =
96
+ (op: "create" | "patch" | "delete") =>
97
+ (e: { message?: string }): Effect.Effect<never, ConfigError> => {
98
+ const underlying = e.message ?? `unknown 400 from Repository ${op}`;
99
+ let hint = "";
100
+ if (/upstream|remote.*validation|HEAD/i.test(underlying)) {
101
+ hint =
102
+ " Artifact Registry validates the upstream remote on create. If the upstream rejects unauthenticated HEAD requests (private nvcr.io paths, etc.), supply `remoteRepositoryConfig.disableUpstreamValidation: true` or attach credentials via `upstreamCredentials`.";
103
+ } else if (/kms|crypto.*key|encrypt/i.test(underlying)) {
104
+ hint =
105
+ " Grant `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the supplied `kmsKeyName` to the Artifact Registry service agent (`service-{projectNumber}@gcp-sa-artifactregistry.iam.gserviceaccount.com`).";
106
+ }
107
+ return Effect.fail(
108
+ new ConfigError({
109
+ message: `Artifact Registry Repository ${op} rejected: ${underlying}.${hint}`,
110
+ }),
111
+ );
112
+ };
@@ -0,0 +1,10 @@
1
+ export {
2
+ ArtifactRegistryRepository,
3
+ ArtifactRegistryRepositoryProvider,
4
+ } from "./Repository.ts";
5
+ export type {
6
+ ArtifactRegistryDockerUpstream,
7
+ ArtifactRegistryRepositoryAttributes,
8
+ ArtifactRegistryRepositoryProps,
9
+ } from "./Repository.ts";
10
+ export type { ArtifactRegistryFormat, ArtifactRegistryMode } from "./Types.ts";
package/src/Providers.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import * as Provider from "alchemy/Provider";
2
2
  import * as Layer from "effect/Layer";
3
+ import {
4
+ ArtifactRegistryRepository,
5
+ ArtifactRegistryRepositoryProvider,
6
+ } from "./ArtifactRegistry/Repository.ts";
3
7
  import { GCPAuth } from "./Auth/AuthProvider.ts";
4
8
  import { fromAuthProvider } from "./Auth/Credentials.ts";
5
9
  import { Project, ProjectProvider } from "./CloudResourceManager/Project.ts";
@@ -17,11 +21,16 @@ import {
17
21
  ManagedLustreInstance,
18
22
  ManagedLustreInstanceProvider,
19
23
  } from "./ManagedLustre/Instance.ts";
24
+ import { Job, JobProvider } from "./Run/Job.ts";
25
+ import { Service, ServiceProvider } from "./Run/Service.ts";
20
26
  import {
21
27
  PsaConnection,
22
28
  PsaConnectionProvider,
23
29
  } from "./ServiceNetworking/PsaConnection.ts";
24
30
  import { ApiEnable, ApiEnableProvider } from "./ServiceUsage/ApiEnable.ts";
31
+ import { SqlDatabase, SqlDatabaseProvider } from "./Sqladmin/Database.ts";
32
+ import { SqlInstance, SqlInstanceProvider } from "./Sqladmin/Instance.ts";
33
+ import { SqlUser, SqlUserProvider } from "./Sqladmin/User.ts";
25
34
 
26
35
  export class Providers extends Provider.ProviderCollection<Providers>()("GCP") {}
27
36
 
@@ -57,6 +66,12 @@ export const providers = () =>
57
66
  SharedVpcServiceProject,
58
67
  PsaConnection,
59
68
  ManagedLustreInstance,
69
+ Service,
70
+ Job,
71
+ SqlInstance,
72
+ SqlDatabase,
73
+ SqlUser,
74
+ ArtifactRegistryRepository,
60
75
  ]),
61
76
  ).pipe(
62
77
  Layer.provide(
@@ -72,6 +87,12 @@ export const providers = () =>
72
87
  SharedVpcServiceProjectProvider(),
73
88
  PsaConnectionProvider(),
74
89
  ManagedLustreInstanceProvider(),
90
+ ServiceProvider(),
91
+ JobProvider(),
92
+ SqlInstanceProvider(),
93
+ SqlDatabaseProvider(),
94
+ SqlUserProvider(),
95
+ ArtifactRegistryRepositoryProvider(),
75
96
  ),
76
97
  ),
77
98
  Layer.provideMerge(fromAuthProvider()),
@@ -0,0 +1,79 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Job } from "./Job.ts";
3
+ import type { Service } from "./Service.ts";
4
+
5
+ /**
6
+ * Bind a single `(role, member)` IAM grant onto a Cloud Run
7
+ * {@link Service}.
8
+ *
9
+ * Target-side binding — see
10
+ * {@link import("../Compute/SubnetworkIamMember.ts").subnetworkIamMember}
11
+ * for the pattern, the SID-collision pitfall, and why `key` exists.
12
+ * Service's `reconcile` merges all bindings into a single
13
+ * `setIamPolicy` call against the service, preserving foreign roles
14
+ * and members on the policy. To remove a binding, drop the call and
15
+ * re-deploy — the provider is additive within the bindings we
16
+ * declared, but does NOT prune.
17
+ *
18
+ * The most common usage:
19
+ *
20
+ * - **Public invoker:** `{ role: "roles/run.invoker", member: "allUsers" }`
21
+ * makes the service publicly reachable. Prefer this over
22
+ * `invokerIamDisabled: true` on the {@link Service} props — declarative
23
+ * IAM is auditable in `setIamPolicy` history; `invokerIamDisabled`
24
+ * is a flag with no audit trail.
25
+ * - **Service-to-service:** a downstream service's runtime SA bound
26
+ * to `roles/run.invoker` on the upstream service.
27
+ *
28
+ * @example Public Cloud Run service
29
+ * ```typescript
30
+ * const api = yield* GCP.Service("PublicApi", { ... });
31
+ * yield* GCP.serviceIamMember(api, "PublicInvoker", {
32
+ * role: "roles/run.invoker",
33
+ * member: "allUsers",
34
+ * });
35
+ * ```
36
+ *
37
+ * @example Service-to-service auth
38
+ * ```typescript
39
+ * const upstream = yield* GCP.Service("Upstream", { ... });
40
+ * yield* GCP.serviceIamMember(upstream, "DownstreamCaller", {
41
+ * role: "roles/run.invoker",
42
+ * member: `serviceAccount:${downstreamSa.email}`,
43
+ * });
44
+ * ```
45
+ */
46
+ export const serviceIamMember = (
47
+ service: Service,
48
+ key: string,
49
+ args: { role: string; member: string },
50
+ ): Effect.Effect<void> =>
51
+ service.bind`IamMember(${service}, ${key})`({
52
+ iamBindings: [{ role: args.role, members: [args.member] }],
53
+ }) as unknown as Effect.Effect<void>;
54
+
55
+ /**
56
+ * Bind a single `(role, member)` IAM grant onto a Cloud Run {@link Job}.
57
+ *
58
+ * Same target-side pattern as {@link serviceIamMember}. The most
59
+ * common Job binding is `roles/run.invoker` on the service account
60
+ * that triggers the job (Cloud Scheduler, Eventarc, Workflows, or a
61
+ * developer's user identity).
62
+ *
63
+ * @example Letting a Cloud Scheduler SA trigger the job
64
+ * ```typescript
65
+ * const nightly = yield* GCP.Job("Nightly", { ... });
66
+ * yield* GCP.jobIamMember(nightly, "SchedulerInvoker", {
67
+ * role: "roles/run.invoker",
68
+ * member: `serviceAccount:${schedulerSa.email}`,
69
+ * });
70
+ * ```
71
+ */
72
+ export const jobIamMember = (
73
+ job: Job,
74
+ key: string,
75
+ args: { role: string; member: string },
76
+ ): Effect.Effect<void> =>
77
+ job.bind`IamMember(${job}, ${key})`({
78
+ iamBindings: [{ role: args.role, members: [args.member] }],
79
+ }) as unknown as Effect.Effect<void>;
@@ -0,0 +1,134 @@
1
+ import type * as run from "@distilled.cloud/gcp/run-v2";
2
+ import type { ResourceBinding } from "alchemy/Resource";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Schedule from "effect/Schedule";
6
+
7
+ /**
8
+ * Common shape of Cloud Run's `getIamPolicy` / `setIamPolicy` request
9
+ * inputs — Service and Job declare nominally distinct request types,
10
+ * but both reduce to `{ resource, body? }` plus the version query for
11
+ * get. We model the operation surface generically so {@link makeSyncIam}
12
+ * can serve both targets without duplication.
13
+ *
14
+ * @internal
15
+ */
16
+ type GetIamPolicyOp = (input: {
17
+ resource: string;
18
+ "options.requestedPolicyVersion"?: number;
19
+ }) => Effect.Effect<run.GoogleIamV1Policy, unknown, never>;
20
+
21
+ type SetIamPolicyOp = (input: {
22
+ resource: string;
23
+ body?: run.GoogleIamV1SetIamPolicyRequest;
24
+ }) => Effect.Effect<run.GoogleIamV1Policy, unknown, never>;
25
+
26
+ /**
27
+ * Single `(role, members)` entry on a Cloud Run target's IAM policy.
28
+ * Identical shape between Service and Job — both Cloud Run resources
29
+ * speak the same `iam.v1.Policy`, so the binding contract is shared.
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * yield* GCP.serviceIamMember(svc, "PublicInvoker", {
34
+ * role: "roles/run.invoker",
35
+ * member: "allUsers",
36
+ * });
37
+ * ```
38
+ */
39
+ export type RunIamBinding = {
40
+ /** IAM role, e.g. `"roles/run.invoker"`. */
41
+ role: string;
42
+ /** Principals, e.g. `["allUsers"]` or `["serviceAccount:foo@bar.iam.gserviceaccount.com"]`. */
43
+ members: ReadonlyArray<string>;
44
+ };
45
+
46
+ /**
47
+ * Binding contract for a Cloud Run target — services and jobs both
48
+ * accept a list of `iamBindings` records via the alchemy `.bind`
49
+ * mechanism. The reconciler reads `bindings` from its arguments and
50
+ * routes them through {@link makeSyncIam}.
51
+ */
52
+ export type RunIamBindingContract = {
53
+ iamBindings: ReadonlyArray<RunIamBinding>;
54
+ };
55
+
56
+ /**
57
+ * Build a reusable `syncIam` step parametrised by the resolved
58
+ * `getIamPolicy` / `setIamPolicy` callables. Cloud Run's Service and
59
+ * Job IAM endpoints have identical request/response shapes (both
60
+ * `resource: "projects/{p}/locations/{l}/{kind}/{n}"`), so the same
61
+ * helper works for both — the caller just hands in the right pair of
62
+ * callables.
63
+ *
64
+ * Semantics, mirroring `Compute/Subnetwork.ts`:
65
+ *
66
+ * - **Union bindings per role across all `.bind` callers.** Multiple
67
+ * capabilities granting the same role get their members merged into
68
+ * a single binding.
69
+ * - **Preserve foreign roles and members verbatim.** We never displace
70
+ * bindings we didn't author. The provider is additive — to remove a
71
+ * binding, drop the `.bind` call and re-deploy (a future revision
72
+ * could narrow this; for now it matches the existing idiom).
73
+ * - **Etag round-trip.** `getIamPolicy` returns an etag; we pass it
74
+ * through `setIamPolicy` so a concurrent edit racing us surfaces as
75
+ * `Conflict`. We retry exponentially up to ~1.5 min — enough to
76
+ * absorb normal CI/human contention without masking real failures.
77
+ */
78
+ export const makeSyncIam =
79
+ (api: { getIamPolicy: GetIamPolicyOp; setIamPolicy: SetIamPolicyOp }) =>
80
+ (args: {
81
+ /** Fully-qualified resource name: `projects/{p}/locations/{l}/{kind}/{n}`. */
82
+ resource: string;
83
+ bindings: ReadonlyArray<ResourceBinding<RunIamBindingContract>>;
84
+ }) =>
85
+ Effect.gen(function* () {
86
+ const desiredByRole = new Map<string, Set<string>>();
87
+ for (const b of args.bindings) {
88
+ for (const ib of b.data.iamBindings) {
89
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
90
+ for (const m of ib.members) set.add(m);
91
+ desiredByRole.set(ib.role, set);
92
+ }
93
+ }
94
+ if (desiredByRole.size === 0) return;
95
+
96
+ const current = yield* api.getIamPolicy({
97
+ resource: args.resource,
98
+ "options.requestedPolicyVersion": 3,
99
+ });
100
+
101
+ const bindings = (current.bindings ?? []).map((b) => ({
102
+ ...b,
103
+ members: [...(b.members ?? [])],
104
+ }));
105
+ let mutated = false;
106
+ for (const [role, members] of desiredByRole) {
107
+ let existing = bindings.find((b) => b.role === role && !b.condition);
108
+ if (!existing) {
109
+ existing = { role, members: [] };
110
+ bindings.push(existing);
111
+ }
112
+ const merged = new Set([...(existing.members ?? []), ...members]);
113
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
114
+ existing.members = [...merged];
115
+ }
116
+ if (!mutated) return;
117
+
118
+ yield* api.setIamPolicy({
119
+ resource: args.resource,
120
+ body: {
121
+ policy: {
122
+ ...current,
123
+ bindings,
124
+ version: 3,
125
+ },
126
+ },
127
+ });
128
+ }).pipe(
129
+ Effect.retry({
130
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
131
+ Schedule.both(Schedule.recurs(8)),
132
+ ),
133
+ }),
134
+ );