@microagi/alchemy-gcp 0.7.0 → 0.8.1

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 (93) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/lib/ArtifactRegistry/Operations.d.ts +2 -2
  3. package/lib/ArtifactRegistry/Operations.d.ts.map +1 -1
  4. package/lib/ArtifactRegistry/Repository.d.ts +1 -1
  5. package/lib/ArtifactRegistry/Validation.d.ts.map +1 -1
  6. package/lib/Auth/AuthProvider.d.ts.map +1 -1
  7. package/lib/Auth/AuthProvider.js.map +1 -1
  8. package/lib/Auth/Credentials.d.ts +1 -1
  9. package/lib/Auth/Credentials.d.ts.map +1 -1
  10. package/lib/CloudResourceManager/Project.d.ts +1 -1
  11. package/lib/CloudResourceManager/ProjectIamMember.d.ts.map +1 -1
  12. package/lib/Compute/Address.d.ts +1 -1
  13. package/lib/Compute/Firewall.d.ts +1 -1
  14. package/lib/Compute/ForwardingRule.d.ts +1 -1
  15. package/lib/Compute/GlobalAddress.d.ts +1 -1
  16. package/lib/Compute/Network.d.ts +1 -1
  17. package/lib/Compute/Network.d.ts.map +1 -1
  18. package/lib/Compute/Operations.d.ts +4 -4
  19. package/lib/Compute/Operations.d.ts.map +1 -1
  20. package/lib/Compute/Subnetwork.d.ts +1 -1
  21. package/lib/Compute/SubnetworkIamMember.d.ts.map +1 -1
  22. package/lib/Container/Cluster.d.ts +1 -1
  23. package/lib/Container/NodePool.d.ts +11 -1
  24. package/lib/Container/NodePool.d.ts.map +1 -1
  25. package/lib/Container/NodePool.js +4 -0
  26. package/lib/Container/NodePool.js.map +1 -1
  27. package/lib/Container/Operations.d.ts +2 -2
  28. package/lib/Container/Operations.d.ts.map +1 -1
  29. package/lib/Iam/ServiceAccount.d.ts +104 -0
  30. package/lib/Iam/ServiceAccount.d.ts.map +1 -0
  31. package/lib/Iam/ServiceAccount.js +255 -0
  32. package/lib/Iam/ServiceAccount.js.map +1 -0
  33. package/lib/Iam/ServiceAccountIamMember.d.ts +49 -0
  34. package/lib/Iam/ServiceAccountIamMember.d.ts.map +1 -0
  35. package/lib/Iam/ServiceAccountIamMember.js +50 -0
  36. package/lib/Iam/ServiceAccountIamMember.js.map +1 -0
  37. package/lib/Iam/ServiceAccountKey.d.ts +72 -0
  38. package/lib/Iam/ServiceAccountKey.d.ts.map +1 -0
  39. package/lib/Iam/ServiceAccountKey.js +164 -0
  40. package/lib/Iam/ServiceAccountKey.js.map +1 -0
  41. package/lib/Iam/index.d.ts +6 -0
  42. package/lib/Iam/index.d.ts.map +1 -0
  43. package/lib/Iam/index.js +4 -0
  44. package/lib/Iam/index.js.map +1 -0
  45. package/lib/Kubernetes/KubernetesManifest.d.ts +1 -1
  46. package/lib/Kubernetes/Secret.d.ts +1 -1
  47. package/lib/Kubernetes/client.d.ts.map +1 -1
  48. package/lib/Kubernetes/connection.d.ts.map +1 -1
  49. package/lib/ManagedLustre/Instance.d.ts +1 -1
  50. package/lib/Providers.d.ts +1 -1
  51. package/lib/Providers.d.ts.map +1 -1
  52. package/lib/Providers.js +7 -1
  53. package/lib/Providers.js.map +1 -1
  54. package/lib/Run/IamMember.d.ts.map +1 -1
  55. package/lib/Run/IamSync.d.ts.map +1 -1
  56. package/lib/Run/Job.d.ts +1 -1
  57. package/lib/Run/Operations.d.ts +2 -2
  58. package/lib/Run/Operations.d.ts.map +1 -1
  59. package/lib/Run/Service.d.ts +1 -1
  60. package/lib/Run/Validation.d.ts.map +1 -1
  61. package/lib/Sqladmin/Instance.d.ts +1 -1
  62. package/lib/Sqladmin/Operations.d.ts +2 -2
  63. package/lib/Sqladmin/Operations.d.ts.map +1 -1
  64. package/lib/Sqladmin/Types.d.ts.map +1 -1
  65. package/lib/Sqladmin/Validation.d.ts.map +1 -1
  66. package/lib/Storage/Bucket.d.ts +117 -0
  67. package/lib/Storage/Bucket.d.ts.map +1 -0
  68. package/lib/Storage/Bucket.js +239 -0
  69. package/lib/Storage/Bucket.js.map +1 -0
  70. package/lib/Storage/BucketIamMember.d.ts +29 -0
  71. package/lib/Storage/BucketIamMember.d.ts.map +1 -0
  72. package/lib/Storage/BucketIamMember.js +26 -0
  73. package/lib/Storage/BucketIamMember.js.map +1 -0
  74. package/lib/Storage/index.d.ts +4 -0
  75. package/lib/Storage/index.d.ts.map +1 -0
  76. package/lib/Storage/index.js +3 -0
  77. package/lib/Storage/index.js.map +1 -0
  78. package/lib/Tags.d.ts.map +1 -1
  79. package/lib/index.d.ts +2 -0
  80. package/lib/index.d.ts.map +1 -1
  81. package/lib/index.js +2 -0
  82. package/lib/index.js.map +1 -1
  83. package/package.json +3 -3
  84. package/src/Container/NodePool.ts +12 -0
  85. package/src/Iam/ServiceAccount.ts +450 -0
  86. package/src/Iam/ServiceAccountIamMember.ts +56 -0
  87. package/src/Iam/ServiceAccountKey.ts +284 -0
  88. package/src/Iam/index.ts +8 -0
  89. package/src/Providers.ts +18 -0
  90. package/src/Storage/Bucket.ts +439 -0
  91. package/src/Storage/BucketIamMember.ts +33 -0
  92. package/src/Storage/index.ts +10 -0
  93. package/src/index.ts +2 -0
@@ -0,0 +1,29 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { StorageBucket } from "./Bucket.ts";
3
+ /**
4
+ * Bind a single `(role, member)` IAM grant onto a
5
+ * {@link StorageBucket}.
6
+ *
7
+ * Target-side binding — same pattern as
8
+ * {@link import("../CloudResourceManager/ProjectIamMember.ts").projectIamMember}
9
+ * / `serviceAccountIamMember`. The bucket's `reconcile` merges all
10
+ * bindings into a single `setIamPolicy` call on the bucket, preserving
11
+ * foreign roles + members.
12
+ *
13
+ * Identity for dedup: the rendered SID is `IamMember(<bucket>, <key>)`.
14
+ * The engine collapses binds by SID, so callers MUST supply a
15
+ * stack-unique `key` per (bucket, role, member) triple.
16
+ *
17
+ * @example Granting a service account read+write on a bucket
18
+ * ```typescript
19
+ * yield* GCP.storageBucketIamMember(bucket, "TempBucketRw", {
20
+ * role: "roles/storage.objectUser",
21
+ * member: `serviceAccount:${sa.email}`,
22
+ * });
23
+ * ```
24
+ */
25
+ export declare const storageBucketIamMember: (bucket: StorageBucket, key: string, args: {
26
+ role: string;
27
+ member: string;
28
+ }) => Effect.Effect<void>;
29
+ //# sourceMappingURL=BucketIamMember.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BucketIamMember.d.ts","sourceRoot":"","sources":["../../src/Storage/BucketIamMember.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,sBAAsB,WACzB,aAAa,OAChB,MAAM,QACL;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KACrC,MAAM,CAAC,MAAM,CAAC,IAAI,CAGiB,CAAC"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Bind a single `(role, member)` IAM grant onto a
3
+ * {@link StorageBucket}.
4
+ *
5
+ * Target-side binding — same pattern as
6
+ * {@link import("../CloudResourceManager/ProjectIamMember.ts").projectIamMember}
7
+ * / `serviceAccountIamMember`. The bucket's `reconcile` merges all
8
+ * bindings into a single `setIamPolicy` call on the bucket, preserving
9
+ * foreign roles + members.
10
+ *
11
+ * Identity for dedup: the rendered SID is `IamMember(<bucket>, <key>)`.
12
+ * The engine collapses binds by SID, so callers MUST supply a
13
+ * stack-unique `key` per (bucket, role, member) triple.
14
+ *
15
+ * @example Granting a service account read+write on a bucket
16
+ * ```typescript
17
+ * yield* GCP.storageBucketIamMember(bucket, "TempBucketRw", {
18
+ * role: "roles/storage.objectUser",
19
+ * member: `serviceAccount:${sa.email}`,
20
+ * });
21
+ * ```
22
+ */
23
+ export const storageBucketIamMember = (bucket, key, args) => bucket.bind `IamMember(${bucket}, ${key})`({
24
+ iamBindings: [{ role: args.role, members: [args.member] }],
25
+ });
26
+ //# sourceMappingURL=BucketIamMember.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BucketIamMember.js","sourceRoot":"","sources":["../../src/Storage/BucketIamMember.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CACpC,MAAqB,EACrB,GAAW,EACX,IAAsC,EACjB,EAAE,CACvB,MAAM,CAAC,IAAI,CAAA,aAAa,MAAM,KAAK,GAAG,GAAG,CAAC;IACxC,WAAW,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;CAC3D,CAAmC,CAAC"}
@@ -0,0 +1,4 @@
1
+ export { StorageBucket, StorageBucketProvider, } from "./Bucket.ts";
2
+ export type { StorageBucketProps, StorageBucketIamBinding, StorageBucketBindingContract, } from "./Bucket.ts";
3
+ export * from "./BucketIamMember.ts";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,4BAA4B,GAC7B,MAAM,aAAa,CAAC;AACrB,cAAc,sBAAsB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { StorageBucket, StorageBucketProvider, } from "./Bucket.js";
2
+ export * from "./BucketIamMember.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/Storage/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,aAAa,EACb,qBAAqB,GACtB,MAAM,aAAa,CAAC;AAMrB,cAAc,sBAAsB,CAAC"}
package/lib/Tags.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"Tags.d.ts","sourceRoot":"","sources":["../src/Tags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAEtC;;;;;;;;;;;;;;GAcG;AAEH,QAAA,MAAM,aAAa,gBAAgB,CAAC;AACpC,QAAA,MAAM,eAAe,kBAAkB,CAAC;AACxC,QAAA,MAAM,YAAY,eAAe,CAAC;AAQlC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB;;;;wBAQ5B,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,0GAW3B,CAAC;AAEH,OAAO,EACL,aAAa,IAAI,iBAAiB,EAClC,eAAe,IAAI,mBAAmB,EACtC,YAAY,IAAI,gBAAgB,GACjC,CAAC;AAsBF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,mGAQhC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,+FAStC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,aAAa,MAAM,GAAG,SAAS,KAAG,MAAM,GAAG,SAI7E,CAAC"}
1
+ {"version":3,"file":"Tags.d.ts","sourceRoot":"","sources":["../src/Tags.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAEtC;;;;;;;;;;;;;;GAcG;AAEH,QAAA,MAAM,aAAa,gBAAgB,CAAC;AACpC,QAAA,MAAM,eAAe,kBAAkB,CAAC;AACxC,QAAA,MAAM,YAAY,eAAe,CAAC;AAQlC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB;;;;wBAQ5B,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,0GAW3B,CAAC;AAEH,OAAO,EACL,aAAa,IAAI,iBAAiB,EAClC,eAAe,IAAI,mBAAmB,EACtC,YAAY,IAAI,gBAAgB,GACjC,CAAC;AAsBF;;;GAGG;AACH,eAAO,MAAM,qBAAqB,mGAQhC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,+FAStC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,gBAAiB,MAAM,GAAG,SAAS,KAAG,MAAM,GAAG,SAI7E,CAAC"}
package/lib/index.d.ts CHANGED
@@ -5,11 +5,13 @@ export * from "./ArtifactRegistry/index.ts";
5
5
  export * from "./CloudResourceManager/index.ts";
6
6
  export * from "./Compute/index.ts";
7
7
  export * from "./Container/index.ts";
8
+ export * from "./Iam/index.ts";
8
9
  export * from "./Kubernetes/index.ts";
9
10
  export * from "./ManagedLustre/index.ts";
10
11
  export * from "./Run/index.ts";
11
12
  export * from "./ServiceNetworking/index.ts";
12
13
  export * from "./Sqladmin/index.ts";
13
14
  export * from "./ServiceUsage/index.ts";
15
+ export * from "./Storage/index.ts";
14
16
  export * from "./Providers.ts";
15
17
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EACL,sBAAsB,EACtB,OAAO,GACR,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EACL,sBAAsB,EACtB,OAAO,GACR,MAAM,wBAAwB,CAAC;AAChC,YAAY,EACV,aAAa,EACb,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC"}
package/lib/index.js CHANGED
@@ -4,11 +4,13 @@ export * from "./ArtifactRegistry/index.js";
4
4
  export * from "./CloudResourceManager/index.js";
5
5
  export * from "./Compute/index.js";
6
6
  export * from "./Container/index.js";
7
+ export * from "./Iam/index.js";
7
8
  export * from "./Kubernetes/index.js";
8
9
  export * from "./ManagedLustre/index.js";
9
10
  export * from "./Run/index.js";
10
11
  export * from "./ServiceNetworking/index.js";
11
12
  export * from "./Sqladmin/index.js";
12
13
  export * from "./ServiceUsage/index.js";
14
+ export * from "./Storage/index.js";
13
15
  export * from "./Providers.js";
14
16
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EACL,sBAAsB,EACtB,OAAO,GACR,MAAM,wBAAwB,CAAC;AAKhC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EACL,sBAAsB,EACtB,OAAO,GACR,MAAM,wBAAwB,CAAC;AAKhC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iCAAiC,CAAC;AAChD,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,gBAAgB,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@microagi/alchemy-gcp",
3
- "version": "0.7.0",
4
- "description": "GCP provider for Alchemy v2 (Effect-based IaC). Resources for Project, Cluster, NodePool, Compute, ManagedLustre, ServiceUsage, ServiceNetworking, Cloud Run (Service + Job + IAM), Cloud SQL (Instance + Database + User with IAM database authn), Artifact Registry (Docker repositories, standard + remote pull-through caches), with ADC-backed credentials and target-side IAM bindings.",
3
+ "version": "0.8.1",
4
+ "description": "GCP provider for Alchemy v2 (Effect-based IaC). Resources for Project, ServiceAccount, Cluster, NodePool, Compute, ManagedLustre, ServiceUsage, ServiceNetworking, Cloud Run (Service + Job + IAM), Cloud SQL (Instance + Database + User with IAM database authn), Artifact Registry (Docker repositories, standard + remote pull-through caches), Kubernetes (typed Secret + generic SSA Manifest), with ADC-backed credentials and target-side IAM bindings.",
5
5
  "keywords": [
6
6
  "alchemy",
7
7
  "alchemy-effect",
@@ -43,7 +43,7 @@
43
43
  }
44
44
  },
45
45
  "scripts": {
46
- "build": "tsc -p tsconfig.build.json",
46
+ "build": "tsgo -p tsconfig.build.json",
47
47
  "clean": "rm -rf lib",
48
48
  "test": "CI=1 bun test",
49
49
  "test:watch": "CI=1 bun test --watch",
@@ -144,6 +144,14 @@ export type NodePoolProps = {
144
144
  gvnic?: { enabled: boolean };
145
145
  /** Confidential VM. Mutable via `update`. */
146
146
  confidentialNodes?: { enabled: boolean };
147
+ /**
148
+ * GCFS (Image Streaming) — stream container image data from Artifact
149
+ * Registry so containers start before the full image is pulled,
150
+ * cutting cold start for large images. Only accelerates images stored
151
+ * in Artifact Registry. Mutable via `update`. Enabling/disabling on an
152
+ * existing pool re-creates its nodes (rolling node recreation).
153
+ */
154
+ gcfsConfig?: { enabled: boolean };
147
155
  };
148
156
  /** Cluster autoscaler. Mutable via `setAutoscaling`. */
149
157
  autoscaling?: {
@@ -295,6 +303,7 @@ export const toNodeConfigCreateBody = (
295
303
  : {}),
296
304
  ...(config.gvnic ? { gvnic: config.gvnic } : {}),
297
305
  ...(config.confidentialNodes ? { confidentialNodes: config.confidentialNodes } : {}),
306
+ ...(config.gcfsConfig ? { gcfsConfig: config.gcfsConfig } : {}),
298
307
  };
299
308
  };
300
309
 
@@ -374,6 +383,9 @@ const toNodePoolUpdateBody = (
374
383
  if (nc.confidentialNodes && !deepEqual(oc.confidentialNodes, nc.confidentialNodes)) {
375
384
  body.confidentialNodes = nc.confidentialNodes;
376
385
  }
386
+ if (nc.gcfsConfig && !deepEqual(oc.gcfsConfig, nc.gcfsConfig)) {
387
+ body.gcfsConfig = nc.gcfsConfig;
388
+ }
377
389
  if (
378
390
  nc.workloadMetadataConfig &&
379
391
  !deepEqual(oc.workloadMetadataConfig, nc.workloadMetadataConfig)
@@ -0,0 +1,450 @@
1
+ import { ConfigError } from "@distilled.cloud/gcp";
2
+ import * as iam from "@distilled.cloud/gcp/unstable/iam-v1";
3
+ import { Resource } from "alchemy";
4
+ import { Unowned } from "alchemy/AdoptPolicy";
5
+ import { isResolved } from "alchemy/Diff";
6
+ import * as Provider from "alchemy/Provider";
7
+ import { diffTags } from "alchemy/Tags";
8
+ import * as Duration from "effect/Duration";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Schedule from "effect/Schedule";
11
+ import {
12
+ descriptionHasAlchemyMarker,
13
+ gcpAlchemyDescription,
14
+ stripAlchemyMarker,
15
+ } from "../Tags.ts";
16
+ import type * as GCP from "../Providers.ts";
17
+
18
+ /**
19
+ * A GCP service account (GSA). Lives under a {@link import("../CloudResourceManager/Project.ts").Project}
20
+ * and serves as the federated identity target for Workload Identity
21
+ * (`iam.gke.io/gcp-service-account: <email>` on a k8s SA) and as the
22
+ * principal in any IAM grant that should out-live a human user.
23
+ *
24
+ * Adoption is gated on an alchemy marker embedded in the GSA's
25
+ * `description` field (see `gcpAlchemyDescription` /
26
+ * `descriptionHasAlchemyMarker`) — GSAs have no labels field, so the
27
+ * label-based adoption gate used by Project can't apply. The marker is
28
+ * only written on insert; `description` is mutable but we never *clear*
29
+ * it because doing so would orphan the adoption signal.
30
+ *
31
+ * All lifecycle operations (`createProjectsServiceAccounts`,
32
+ * `patchProjectsServiceAccounts`, `deleteProjectsServiceAccounts`) are
33
+ * synchronous — no LRO polling required.
34
+ *
35
+ * @section Creating a ServiceAccount
36
+ * @example Minimal GSA under a project
37
+ * ```typescript
38
+ * const gsa = yield* GCP.ServiceAccount("Builder", {
39
+ * project: project.projectId,
40
+ * accountId: "research-builder",
41
+ * displayName: "Research pod build + push (BuildKit client)",
42
+ * });
43
+ * ```
44
+ *
45
+ * @example With explicit description and Workload Identity binding
46
+ * ```typescript
47
+ * const gsa = yield* GCP.ServiceAccount("Builder", {
48
+ * project: project.projectId,
49
+ * accountId: "research-builder",
50
+ * displayName: "Research pod build + push",
51
+ * description: "Used by per-researcher BuildKit clients to push to AR.",
52
+ * });
53
+ * yield* GCP.serviceAccountIamMember(gsa, "ClusterA-WorkloadIdentity", {
54
+ * role: "roles/iam.workloadIdentityUser",
55
+ * member: `serviceAccount:${projectA.projectId}.svc.id.goog[researchers/build]`,
56
+ * });
57
+ * ```
58
+ */
59
+ export type ServiceAccountProps = {
60
+ /**
61
+ * GCP project id (e.g. `micro-research-shared`) the GSA lives under.
62
+ * Immutable — changing this orphans the GSA's unique id and any
63
+ * bindings, so `diff` triggers a replacement.
64
+ */
65
+ project: string;
66
+ /**
67
+ * The account id segment of the service account's email. Must match
68
+ * `[a-z]([-a-z0-9]*[a-z0-9])`, 6-30 chars. Immutable — replaces.
69
+ */
70
+ accountId: string;
71
+ /**
72
+ * User-visible display name. Max 100 UTF-8 bytes. Mutable.
73
+ */
74
+ displayName?: string;
75
+ /**
76
+ * User-supplied description (max 256 UTF-8 bytes). The alchemy
77
+ * ownership marker is prepended automatically — see
78
+ * `gcpAlchemyDescription`. The user-supplied portion is preserved
79
+ * verbatim and surfaced back through `Attributes.description`.
80
+ */
81
+ description?: string;
82
+ };
83
+
84
+ /**
85
+ * Single (role, members) entry on a GSA's IAM policy. Targets declare
86
+ * this contract so capabilities (e.g. Workload Identity) can `.bind`
87
+ * IAM grants onto the GSA — the provider's `reconcile` merges all
88
+ * bound entries by role into a single `setIamPolicy` call.
89
+ */
90
+ export type ServiceAccountIamBinding = {
91
+ /** IAM role, e.g. `"roles/iam.workloadIdentityUser"`. */
92
+ role: string;
93
+ /** Principals, e.g. `["serviceAccount:foo.svc.id.goog[ns/ksa]"]`. */
94
+ members: ReadonlyArray<string>;
95
+ };
96
+
97
+ export type ServiceAccountBindingContract = {
98
+ iamBindings: ReadonlyArray<ServiceAccountIamBinding>;
99
+ };
100
+
101
+ export type ServiceAccount = Resource<
102
+ "GCP.ServiceAccount",
103
+ ServiceAccountProps,
104
+ {
105
+ /** Full resource name, e.g. `projects/micro-research-shared/serviceAccounts/research-builder@micro-research-shared.iam.gserviceaccount.com`. */
106
+ name: string;
107
+ /** Email address derived from the account id, e.g. `research-builder@micro-research-shared.iam.gserviceaccount.com`. */
108
+ email: string;
109
+ /** Server-assigned stable numeric id. */
110
+ uniqueId: string;
111
+ /** Project the GSA lives under. */
112
+ projectId: string;
113
+ /** Account id (the part before `@`). */
114
+ accountId: string;
115
+ /** User-visible display name, or `undefined` if never set. */
116
+ displayName: string | undefined;
117
+ /** User-visible description (without the alchemy marker), or `undefined` if never set. */
118
+ description: string | undefined;
119
+ /** Whether the GSA is currently disabled. */
120
+ disabled: boolean;
121
+ },
122
+ ServiceAccountBindingContract,
123
+ GCP.Providers
124
+ >;
125
+
126
+ export const ServiceAccount = Resource<ServiceAccount>("GCP.ServiceAccount");
127
+
128
+ const toAttributes = (sa: iam.ServiceAccount): ServiceAccount["Attributes"] => {
129
+ const accountId = (sa.email ?? "").split("@")[0];
130
+ return {
131
+ name: sa.name ?? "",
132
+ email: sa.email ?? "",
133
+ uniqueId: sa.uniqueId ?? "",
134
+ projectId: sa.projectId ?? "",
135
+ accountId,
136
+ displayName: sa.displayName,
137
+ description: stripAlchemyMarker(sa.description),
138
+ disabled: sa.disabled ?? false,
139
+ };
140
+ };
141
+
142
+ export const ServiceAccountProvider = () =>
143
+ Provider.effect(
144
+ ServiceAccount,
145
+ Effect.gen(function* () {
146
+ const getProjectsServiceAccounts = yield* iam.getProjectsServiceAccounts;
147
+ const createProjectsServiceAccounts = yield* iam.createProjectsServiceAccounts;
148
+ const patchProjectsServiceAccounts = yield* iam.patchProjectsServiceAccounts;
149
+ const deleteProjectsServiceAccounts = yield* iam.deleteProjectsServiceAccounts;
150
+ const listProjectsServiceAccounts = yield* iam.listProjectsServiceAccounts;
151
+ const getIamPolicyProjectsServiceAccounts = yield* iam.getIamPolicyProjectsServiceAccounts;
152
+ const setIamPolicyProjectsServiceAccounts = yield* iam.setIamPolicyProjectsServiceAccounts;
153
+
154
+ const resourceName = (projectId: string, accountId: string) =>
155
+ `projects/${projectId}/serviceAccounts/${accountId}`;
156
+
157
+ const observeServiceAccount = (projectId: string, accountId: string) =>
158
+ getProjectsServiceAccounts({
159
+ name: resourceName(projectId, accountId),
160
+ }).pipe(
161
+ // GSAs that don't exist OR that the caller can't see both
162
+ // collapse to "absent" — same 403→missing pattern as the
163
+ // project observer.
164
+ Effect.catchTag("NotFound", () =>
165
+ Effect.succeed(undefined as iam.ServiceAccount | undefined),
166
+ ),
167
+ Effect.catchTag("Forbidden", () =>
168
+ Effect.succeed(undefined as iam.ServiceAccount | undefined),
169
+ ),
170
+ );
171
+
172
+ const requireServiceAccount = (
173
+ projectId: string,
174
+ accountId: string,
175
+ context: string,
176
+ ) =>
177
+ observeServiceAccount(projectId, accountId).pipe(
178
+ Effect.flatMap((sa) =>
179
+ sa
180
+ ? Effect.succeed(sa)
181
+ : Effect.fail(
182
+ new ConfigError({
183
+ message: `ServiceAccount ${accountId} in project ${projectId} ${context}.`,
184
+ }),
185
+ ),
186
+ ),
187
+ );
188
+
189
+ // List-page scan for cold recovery. Alchemy marker in the
190
+ // description field identifies our GSAs; the page boundary is
191
+ // arbitrary — we still need to page through to find any marker
192
+ // that happens to fall past the first page.
193
+ const findByAlchemyMarker = Effect.fn(function* (
194
+ id: string,
195
+ projectId: string,
196
+ ) {
197
+ let pageToken: string | undefined;
198
+ // Up to 100 pages × 100 items per page = 10000 GSAs; well past
199
+ // any realistic project size. If we ever exceed that we should
200
+ // cap and warn rather than silently miss matches.
201
+ for (let i = 0; i < 100; i++) {
202
+ const page = yield* listProjectsServiceAccounts({
203
+ name: `projects/${projectId}`,
204
+ ...(pageToken ? { pageToken } : {}),
205
+ pageSize: 100,
206
+ });
207
+ for (const sa of page.accounts ?? []) {
208
+ if (yield* descriptionHasAlchemyMarker(id, sa.description)) {
209
+ return sa;
210
+ }
211
+ }
212
+ pageToken = page.nextPageToken;
213
+ if (!pageToken) return undefined;
214
+ }
215
+ return undefined;
216
+ });
217
+
218
+ // Sync mutable fields: displayName + description. Both use
219
+ // `updateMask` so we only PATCH what actually changed. Diff
220
+ // against OBSERVED state, not `olds`, so adoption converges
221
+ // correctly when a foreign description is on the GSA.
222
+ const syncMutable = Effect.fn(function* (
223
+ observed: iam.ServiceAccount,
224
+ desired: {
225
+ displayName: string | undefined;
226
+ descriptionWithMarker: string;
227
+ descriptionUserOnly: string | undefined;
228
+ },
229
+ ) {
230
+ const updateMaskFields: string[] = [];
231
+ if (desired.displayName !== observed.displayName) {
232
+ updateMaskFields.push("display_name");
233
+ }
234
+ const descDiff = diffTags(
235
+ { description: observed.description ?? "" },
236
+ { description: desired.descriptionWithMarker },
237
+ );
238
+ if (
239
+ descDiff.removed.length > 0 ||
240
+ descDiff.upsert.length > 0
241
+ ) {
242
+ updateMaskFields.push("description");
243
+ }
244
+
245
+ if (updateMaskFields.length === 0) return observed;
246
+
247
+ return yield* patchProjectsServiceAccounts({
248
+ name: observed.name!,
249
+ body: {
250
+ updateMask: updateMaskFields.join(","),
251
+ serviceAccount: {
252
+ displayName: desired.displayName,
253
+ description: desired.descriptionWithMarker,
254
+ },
255
+ },
256
+ });
257
+ });
258
+
259
+ // Apply merged IAM bindings as a single setIamPolicy. Same
260
+ // foreign-binding preservation + etag-retry pattern as the
261
+ // Project's `syncIam` — see `src/CloudResourceManager/Project.ts:421-474`.
262
+ const syncIam = (args: {
263
+ resourceName: string;
264
+ bindings: ReadonlyArray<
265
+ import("alchemy/Resource").ResourceBinding<ServiceAccountBindingContract>
266
+ >;
267
+ }) =>
268
+ Effect.gen(function* () {
269
+ const desiredByRole = new Map<string, Set<string>>();
270
+ for (const b of args.bindings) {
271
+ for (const ib of b.data.iamBindings) {
272
+ const set = desiredByRole.get(ib.role) ?? new Set<string>();
273
+ for (const m of ib.members) set.add(m);
274
+ desiredByRole.set(ib.role, set);
275
+ }
276
+ }
277
+ if (desiredByRole.size === 0) return;
278
+
279
+ const current = yield* getIamPolicyProjectsServiceAccounts({
280
+ resource: args.resourceName,
281
+ "options.requestedPolicyVersion": 3,
282
+ });
283
+
284
+ const existingBindings = (current.bindings ?? []).map((b) => ({
285
+ ...b,
286
+ members: [...(b.members ?? [])],
287
+ }));
288
+ let mutated = false;
289
+ for (const [role, members] of desiredByRole) {
290
+ let existing = existingBindings.find(
291
+ (b) => b.role === role && !b.condition,
292
+ );
293
+ if (!existing) {
294
+ existing = { role, members: [] };
295
+ existingBindings.push(existing);
296
+ }
297
+ const merged = new Set([...(existing.members ?? []), ...members]);
298
+ if (merged.size !== (existing.members?.length ?? 0)) mutated = true;
299
+ existing.members = [...merged];
300
+ }
301
+ if (!mutated) return;
302
+
303
+ yield* setIamPolicyProjectsServiceAccounts({
304
+ resource: args.resourceName,
305
+ body: {
306
+ policy: { ...current, bindings: existingBindings, version: 3 },
307
+ },
308
+ });
309
+ }).pipe(
310
+ Effect.retry({
311
+ schedule: Schedule.exponential(Duration.seconds(2)).pipe(
312
+ Schedule.both(Schedule.recurs(8)),
313
+ ),
314
+ }),
315
+ );
316
+
317
+ return {
318
+ // Stable identity: accountId + project; both immutable; either
319
+ // change → replace. The server-assigned uniqueId is also stable
320
+ // for the lifetime of the GSA (re-create gets a different one).
321
+ stables: ["accountId", "projectId", "uniqueId", "name", "email"],
322
+ diff: Effect.fn(function* ({ id, news, olds = {}, output }) {
323
+ if (!isResolved(news)) return undefined;
324
+ // Replace ONLY when a prior identity is actually known AND differs.
325
+ // A partial/adopted state can lack the persisted accountId/projectId
326
+ // (an adoption that never persisted full Attributes leaves
327
+ // output.projectId === "" / undefined). Treating that "unknown" as a
328
+ // replace spuriously deletes+recreates a live, correctly-named GSA
329
+ // (and churns its uniqueId → breaks Workload-Identity bindings).
330
+ // When the prior identity is absent, fall through to reconcile, which
331
+ // observes the live resource and re-persists its Attributes. The
332
+ // truthiness guard covers both `undefined` and the `""` toAttributes
333
+ // default; a genuine accountId/project change still has a non-empty
334
+ // prior value to compare against, so real replacements are unaffected.
335
+ // `||` (not `??`): toAttributes persists `accountId`/`projectId` as
336
+ // `""` when absent, and an empty string must fall through to `olds`
337
+ // (the previous Props) rather than mask it — otherwise a genuine
338
+ // identity change with an empty `output` but a populated `olds` would
339
+ // skip the replace and adopt/create at the new identity without
340
+ // deleting the old GSA.
341
+ const priorAccount = output?.accountId || olds.accountId;
342
+ const priorProject = output?.projectId || olds.project;
343
+ if (
344
+ (priorAccount && news.accountId !== priorAccount) ||
345
+ (priorProject && news.project !== priorProject)
346
+ ) {
347
+ return { action: "replace" } as const;
348
+ }
349
+ return undefined;
350
+ }),
351
+ reconcile: Effect.fn(function* ({ id, news, bindings }) {
352
+ const descriptionWithMarker = yield* gcpAlchemyDescription(
353
+ id,
354
+ news.description,
355
+ );
356
+
357
+ // 1. Observe — cloud state is authoritative. A second
358
+ // scan-by-marker covers the cold-recovery case where
359
+ // persisted `output`/`olds` lost the uniqueId after
360
+ // state loss. We prefer the direct-by-name probe first
361
+ // (cheap) and fall back to the scan only when direct
362
+ // observation returned `undefined`.
363
+ let observed = yield* observeServiceAccount(news.project, news.accountId);
364
+ if (!observed) {
365
+ observed = yield* findByAlchemyMarker(id, news.project);
366
+ }
367
+
368
+ // 2. Ensure — create if missing. createProjectsServiceAccounts
369
+ // is synchronous (returns the SA, not an LRO). Conflict
370
+ // covers a peer reconciler race — re-observe.
371
+ if (!observed) {
372
+ observed = yield* createProjectsServiceAccounts({
373
+ name: `projects/${news.project}`,
374
+ body: {
375
+ accountId: news.accountId,
376
+ serviceAccount: {
377
+ displayName: news.displayName,
378
+ description: descriptionWithMarker,
379
+ },
380
+ },
381
+ }).pipe(
382
+ Effect.catchTag("Conflict", () =>
383
+ Effect.succeed(undefined as iam.ServiceAccount | undefined),
384
+ ),
385
+ );
386
+
387
+ if (!observed) {
388
+ observed = yield* requireServiceAccount(
389
+ news.project,
390
+ news.accountId,
391
+ "did not appear after create",
392
+ );
393
+ }
394
+ }
395
+
396
+ // 3. Sync mutable fields against OBSERVED state.
397
+ const synced = yield* syncMutable(observed, {
398
+ displayName: news.displayName ?? observed.displayName,
399
+ descriptionWithMarker,
400
+ descriptionUserOnly: news.description,
401
+ });
402
+
403
+ // 4. Sync IAM bindings (Workload Identity, impersonators, …).
404
+ // Single setIamPolicy on the GSA with merged bindings;
405
+ // foreign roles preserved. Etag-retried internally.
406
+ yield* syncIam({
407
+ resourceName: synced.name!,
408
+ bindings,
409
+ });
410
+
411
+ return toAttributes(synced);
412
+ }),
413
+ delete: Effect.fn(function* ({ output }) {
414
+ // Synchronous delete — Empty response, no LRO. NotFound
415
+ // means the GSA was already deleted (or never existed in our
416
+ // scope); both are idempotent successes.
417
+ yield* deleteProjectsServiceAccounts({
418
+ name: output.name,
419
+ }).pipe(
420
+ Effect.catchTag("NotFound", () => Effect.void),
421
+ Effect.catchTag("Forbidden", () => Effect.void),
422
+ );
423
+ }),
424
+ read: Effect.fn(function* ({ id, output, olds }) {
425
+ // Direct-by-name probe when we have any handle on the
426
+ // accountId. Falls back to a project-wide scan-by-marker
427
+ // for cold recovery after state loss.
428
+ const accountId =
429
+ output?.accountId ?? olds?.accountId;
430
+ const projectId =
431
+ output?.projectId ?? olds?.project;
432
+
433
+ let observed: iam.ServiceAccount | undefined;
434
+ if (accountId && projectId) {
435
+ observed = yield* observeServiceAccount(projectId, accountId);
436
+ }
437
+ if (!observed && projectId) {
438
+ observed = yield* findByAlchemyMarker(id, projectId);
439
+ }
440
+ if (!observed) return undefined;
441
+
442
+ const attrs = toAttributes(observed);
443
+ // Adoption gate via description marker (see Tags.ts §description).
444
+ return (yield* descriptionHasAlchemyMarker(id, observed.description))
445
+ ? attrs
446
+ : Unowned(attrs);
447
+ }),
448
+ };
449
+ }),
450
+ );