@microagi/alchemy-gcp 0.7.0 → 0.8.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.
- package/CHANGELOG.md +39 -0
- package/lib/Iam/ServiceAccount.d.ts +104 -0
- package/lib/Iam/ServiceAccount.d.ts.map +1 -0
- package/lib/Iam/ServiceAccount.js +236 -0
- package/lib/Iam/ServiceAccount.js.map +1 -0
- package/lib/Iam/ServiceAccountIamMember.d.ts +49 -0
- package/lib/Iam/ServiceAccountIamMember.d.ts.map +1 -0
- package/lib/Iam/ServiceAccountIamMember.js +50 -0
- package/lib/Iam/ServiceAccountIamMember.js.map +1 -0
- package/lib/Iam/index.d.ts +4 -0
- package/lib/Iam/index.d.ts.map +1 -0
- package/lib/Iam/index.js +3 -0
- package/lib/Iam/index.js.map +1 -0
- package/lib/Providers.d.ts.map +1 -1
- package/lib/Providers.js +3 -1
- package/lib/Providers.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Iam/ServiceAccount.ts +431 -0
- package/src/Iam/ServiceAccountIamMember.ts +56 -0
- package/src/Iam/index.ts +3 -0
- package/src/Providers.ts +6 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ All notable changes to `@microagi/alchemy-gcp`. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this package
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 0.8.0 — 2026-06-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`GCP.ServiceAccount`** — Alchemy resource for managing Google
|
|
12
|
+
Cloud service accounts (GSAs). Wraps the iam-v1 API
|
|
13
|
+
(`@distilled.cloud/gcp/unstable/iam-v1` — the `iamcredentials-v1`
|
|
14
|
+
surface was already in the stable index; `iam-v1` lives under
|
|
15
|
+
`unstable/` because the auto-generated codegen hasn't been manually
|
|
16
|
+
reviewed). Adoption gated on an alchemy marker embedded in the
|
|
17
|
+
GSA's `description` field (GSAs have no labels field, so the
|
|
18
|
+
label-based pattern used by `GCP.Project` doesn't apply — see
|
|
19
|
+
`Tags.descriptionHasAlchemyMarker`). Cold recovery (state loss)
|
|
20
|
+
falls back to a project-wide scan-by-marker when persisted
|
|
21
|
+
`output`/`olds` lack the `uniqueId`. Synchronous lifecycle — no LRO
|
|
22
|
+
polling required.
|
|
23
|
+
|
|
24
|
+
- **`GCP.serviceAccountIamMember`** — target-side binding helper, the
|
|
25
|
+
same shape as the existing `projectIamMember` and
|
|
26
|
+
`subnetworkIamMember`. Records a `(role, member)` grant onto the
|
|
27
|
+
GSA's binding bag; the ServiceAccount provider's `reconcile` merges
|
|
28
|
+
all bound entries by role into a single `setIamPolicy` call on the
|
|
29
|
+
GSA. Foreign bindings on the policy (non-alchemy roles, or members
|
|
30
|
+
we don't manage on the same role) are preserved verbatim. Etag
|
|
31
|
+
round-trip handles concurrent edits; `Conflict` triggers a
|
|
32
|
+
re-read + retry.
|
|
33
|
+
|
|
34
|
+
### Use cases
|
|
35
|
+
|
|
36
|
+
- Workload Identity bindings — `roles/iam.workloadIdentityUser` on a
|
|
37
|
+
GSA from a k8s SA. Replaces the manual `gcloud iam service-accounts
|
|
38
|
+
add-iam-policy-binding` dance that's been applied out-of-band for
|
|
39
|
+
the `research-ui` and `preview-builder` GSAs.
|
|
40
|
+
- Per-namespace `roles/artifactregistry.writer` on a GSA scoped to
|
|
41
|
+
a single research pod (instead of project-wide via
|
|
42
|
+
`projectIamMember`).
|
|
43
|
+
- Impersonation grants — `roles/iam.serviceAccountTokenCreator` /
|
|
44
|
+
`roles/iam.serviceAccountUser`.
|
|
45
|
+
|
|
7
46
|
## 0.7.0 — 2026-06-16
|
|
8
47
|
|
|
9
48
|
### Added
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Resource } from "alchemy";
|
|
2
|
+
import * as Provider from "alchemy/Provider";
|
|
3
|
+
import type * as GCP from "../Providers.ts";
|
|
4
|
+
/**
|
|
5
|
+
* A GCP service account (GSA). Lives under a {@link import("../CloudResourceManager/Project.ts").Project}
|
|
6
|
+
* and serves as the federated identity target for Workload Identity
|
|
7
|
+
* (`iam.gke.io/gcp-service-account: <email>` on a k8s SA) and as the
|
|
8
|
+
* principal in any IAM grant that should out-live a human user.
|
|
9
|
+
*
|
|
10
|
+
* Adoption is gated on an alchemy marker embedded in the GSA's
|
|
11
|
+
* `description` field (see `gcpAlchemyDescription` /
|
|
12
|
+
* `descriptionHasAlchemyMarker`) — GSAs have no labels field, so the
|
|
13
|
+
* label-based adoption gate used by Project can't apply. The marker is
|
|
14
|
+
* only written on insert; `description` is mutable but we never *clear*
|
|
15
|
+
* it because doing so would orphan the adoption signal.
|
|
16
|
+
*
|
|
17
|
+
* All lifecycle operations (`createProjectsServiceAccounts`,
|
|
18
|
+
* `patchProjectsServiceAccounts`, `deleteProjectsServiceAccounts`) are
|
|
19
|
+
* synchronous — no LRO polling required.
|
|
20
|
+
*
|
|
21
|
+
* @section Creating a ServiceAccount
|
|
22
|
+
* @example Minimal GSA under a project
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const gsa = yield* GCP.ServiceAccount("Builder", {
|
|
25
|
+
* project: project.projectId,
|
|
26
|
+
* accountId: "research-builder",
|
|
27
|
+
* displayName: "Research pod build + push (BuildKit client)",
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @example With explicit description and Workload Identity binding
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const gsa = yield* GCP.ServiceAccount("Builder", {
|
|
34
|
+
* project: project.projectId,
|
|
35
|
+
* accountId: "research-builder",
|
|
36
|
+
* displayName: "Research pod build + push",
|
|
37
|
+
* description: "Used by per-researcher BuildKit clients to push to AR.",
|
|
38
|
+
* });
|
|
39
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterA-WorkloadIdentity", {
|
|
40
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
41
|
+
* member: `serviceAccount:${projectA.projectId}.svc.id.goog[researchers/build]`,
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export type ServiceAccountProps = {
|
|
46
|
+
/**
|
|
47
|
+
* GCP project id (e.g. `micro-research-shared`) the GSA lives under.
|
|
48
|
+
* Immutable — changing this orphans the GSA's unique id and any
|
|
49
|
+
* bindings, so `diff` triggers a replacement.
|
|
50
|
+
*/
|
|
51
|
+
project: string;
|
|
52
|
+
/**
|
|
53
|
+
* The account id segment of the service account's email. Must match
|
|
54
|
+
* `[a-z]([-a-z0-9]*[a-z0-9])`, 6-30 chars. Immutable — replaces.
|
|
55
|
+
*/
|
|
56
|
+
accountId: string;
|
|
57
|
+
/**
|
|
58
|
+
* User-visible display name. Max 100 UTF-8 bytes. Mutable.
|
|
59
|
+
*/
|
|
60
|
+
displayName?: string;
|
|
61
|
+
/**
|
|
62
|
+
* User-supplied description (max 256 UTF-8 bytes). The alchemy
|
|
63
|
+
* ownership marker is prepended automatically — see
|
|
64
|
+
* `gcpAlchemyDescription`. The user-supplied portion is preserved
|
|
65
|
+
* verbatim and surfaced back through `Attributes.description`.
|
|
66
|
+
*/
|
|
67
|
+
description?: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Single (role, members) entry on a GSA's IAM policy. Targets declare
|
|
71
|
+
* this contract so capabilities (e.g. Workload Identity) can `.bind`
|
|
72
|
+
* IAM grants onto the GSA — the provider's `reconcile` merges all
|
|
73
|
+
* bound entries by role into a single `setIamPolicy` call.
|
|
74
|
+
*/
|
|
75
|
+
export type ServiceAccountIamBinding = {
|
|
76
|
+
/** IAM role, e.g. `"roles/iam.workloadIdentityUser"`. */
|
|
77
|
+
role: string;
|
|
78
|
+
/** Principals, e.g. `["serviceAccount:foo.svc.id.goog[ns/ksa]"]`. */
|
|
79
|
+
members: ReadonlyArray<string>;
|
|
80
|
+
};
|
|
81
|
+
export type ServiceAccountBindingContract = {
|
|
82
|
+
iamBindings: ReadonlyArray<ServiceAccountIamBinding>;
|
|
83
|
+
};
|
|
84
|
+
export type ServiceAccount = Resource<"GCP.ServiceAccount", ServiceAccountProps, {
|
|
85
|
+
/** Full resource name, e.g. `projects/micro-research-shared/serviceAccounts/research-builder@micro-research-shared.iam.gserviceaccount.com`. */
|
|
86
|
+
name: string;
|
|
87
|
+
/** Email address derived from the account id, e.g. `research-builder@micro-research-shared.iam.gserviceaccount.com`. */
|
|
88
|
+
email: string;
|
|
89
|
+
/** Server-assigned stable numeric id. */
|
|
90
|
+
uniqueId: string;
|
|
91
|
+
/** Project the GSA lives under. */
|
|
92
|
+
projectId: string;
|
|
93
|
+
/** Account id (the part before `@`). */
|
|
94
|
+
accountId: string;
|
|
95
|
+
/** User-visible display name, or `undefined` if never set. */
|
|
96
|
+
displayName: string | undefined;
|
|
97
|
+
/** User-visible description (without the alchemy marker), or `undefined` if never set. */
|
|
98
|
+
description: string | undefined;
|
|
99
|
+
/** Whether the GSA is currently disabled. */
|
|
100
|
+
disabled: boolean;
|
|
101
|
+
}, ServiceAccountBindingContract, GCP.Providers>;
|
|
102
|
+
export declare const ServiceAccount: import("alchemy").ResourceClass<ServiceAccount>;
|
|
103
|
+
export declare const ServiceAccountProvider: () => import("effect/Layer").Layer<Provider.Provider<ServiceAccount>, never, import("alchemy").Stack | import("alchemy").Stage | import("@distilled.cloud/gcp").Credentials | import("effect/unstable/http/HttpClient").HttpClient>;
|
|
104
|
+
//# sourceMappingURL=ServiceAccount.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ServiceAccount.d.ts","sourceRoot":"","sources":["../../src/Iam/ServiceAccount.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAGnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAU7C,OAAO,KAAK,KAAK,GAAG,MAAM,iBAAiB,CAAC;AAE5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG;IAC1C,WAAW,EAAE,aAAa,CAAC,wBAAwB,CAAC,CAAC;CACtD,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,QAAQ,CACnC,oBAAoB,EACpB,mBAAmB,EACnB;IACE,gJAAgJ;IAChJ,IAAI,EAAE,MAAM,CAAC;IACb,wHAAwH;IACxH,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,mCAAmC;IACnC,SAAS,EAAE,MAAM,CAAC;IAClB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;IAClB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,0FAA0F;IAC1F,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,6CAA6C;IAC7C,QAAQ,EAAE,OAAO,CAAC;CACnB,EACD,6BAA6B,EAC7B,GAAG,CAAC,SAAS,CACd,CAAC;AAEF,eAAO,MAAM,cAAc,iDAAiD,CAAC;AAgB7E,eAAO,MAAM,sBAAsB,qOAiShC,CAAC"}
|
|
@@ -0,0 +1,236 @@
|
|
|
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 { descriptionHasAlchemyMarker, gcpAlchemyDescription, stripAlchemyMarker, } from "../Tags.js";
|
|
12
|
+
export const ServiceAccount = Resource("GCP.ServiceAccount");
|
|
13
|
+
const toAttributes = (sa) => {
|
|
14
|
+
const accountId = (sa.email ?? "").split("@")[0];
|
|
15
|
+
return {
|
|
16
|
+
name: sa.name ?? "",
|
|
17
|
+
email: sa.email ?? "",
|
|
18
|
+
uniqueId: sa.uniqueId ?? "",
|
|
19
|
+
projectId: sa.projectId ?? "",
|
|
20
|
+
accountId,
|
|
21
|
+
displayName: sa.displayName,
|
|
22
|
+
description: stripAlchemyMarker(sa.description),
|
|
23
|
+
disabled: sa.disabled ?? false,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export const ServiceAccountProvider = () => Provider.effect(ServiceAccount, Effect.gen(function* () {
|
|
27
|
+
const getProjectsServiceAccounts = yield* iam.getProjectsServiceAccounts;
|
|
28
|
+
const createProjectsServiceAccounts = yield* iam.createProjectsServiceAccounts;
|
|
29
|
+
const patchProjectsServiceAccounts = yield* iam.patchProjectsServiceAccounts;
|
|
30
|
+
const deleteProjectsServiceAccounts = yield* iam.deleteProjectsServiceAccounts;
|
|
31
|
+
const listProjectsServiceAccounts = yield* iam.listProjectsServiceAccounts;
|
|
32
|
+
const getIamPolicyProjectsServiceAccounts = yield* iam.getIamPolicyProjectsServiceAccounts;
|
|
33
|
+
const setIamPolicyProjectsServiceAccounts = yield* iam.setIamPolicyProjectsServiceAccounts;
|
|
34
|
+
const resourceName = (projectId, accountId) => `projects/${projectId}/serviceAccounts/${accountId}`;
|
|
35
|
+
const observeServiceAccount = (projectId, accountId) => getProjectsServiceAccounts({
|
|
36
|
+
name: resourceName(projectId, accountId),
|
|
37
|
+
}).pipe(
|
|
38
|
+
// GSAs that don't exist OR that the caller can't see both
|
|
39
|
+
// collapse to "absent" — same 403→missing pattern as the
|
|
40
|
+
// project observer.
|
|
41
|
+
Effect.catchTag("NotFound", () => Effect.succeed(undefined)), Effect.catchTag("Forbidden", () => Effect.succeed(undefined)));
|
|
42
|
+
const requireServiceAccount = (projectId, accountId, context) => observeServiceAccount(projectId, accountId).pipe(Effect.flatMap((sa) => sa
|
|
43
|
+
? Effect.succeed(sa)
|
|
44
|
+
: Effect.fail(new ConfigError({
|
|
45
|
+
message: `ServiceAccount ${accountId} in project ${projectId} ${context}.`,
|
|
46
|
+
}))));
|
|
47
|
+
// List-page scan for cold recovery. Alchemy marker in the
|
|
48
|
+
// description field identifies our GSAs; the page boundary is
|
|
49
|
+
// arbitrary — we still need to page through to find any marker
|
|
50
|
+
// that happens to fall past the first page.
|
|
51
|
+
const findByAlchemyMarker = Effect.fn(function* (id, projectId) {
|
|
52
|
+
let pageToken;
|
|
53
|
+
// Up to 100 pages × 100 items per page = 10000 GSAs; well past
|
|
54
|
+
// any realistic project size. If we ever exceed that we should
|
|
55
|
+
// cap and warn rather than silently miss matches.
|
|
56
|
+
for (let i = 0; i < 100; i++) {
|
|
57
|
+
const page = yield* listProjectsServiceAccounts({
|
|
58
|
+
name: `projects/${projectId}`,
|
|
59
|
+
...(pageToken ? { pageToken } : {}),
|
|
60
|
+
pageSize: 100,
|
|
61
|
+
});
|
|
62
|
+
for (const sa of page.accounts ?? []) {
|
|
63
|
+
if (yield* descriptionHasAlchemyMarker(id, sa.description)) {
|
|
64
|
+
return sa;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
pageToken = page.nextPageToken;
|
|
68
|
+
if (!pageToken)
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
});
|
|
73
|
+
// Sync mutable fields: displayName + description. Both use
|
|
74
|
+
// `updateMask` so we only PATCH what actually changed. Diff
|
|
75
|
+
// against OBSERVED state, not `olds`, so adoption converges
|
|
76
|
+
// correctly when a foreign description is on the GSA.
|
|
77
|
+
const syncMutable = Effect.fn(function* (observed, desired) {
|
|
78
|
+
const updateMaskFields = [];
|
|
79
|
+
if (desired.displayName !== observed.displayName) {
|
|
80
|
+
updateMaskFields.push("display_name");
|
|
81
|
+
}
|
|
82
|
+
const descDiff = diffTags({ description: observed.description ?? "" }, { description: desired.descriptionWithMarker });
|
|
83
|
+
if (descDiff.removed.length > 0 ||
|
|
84
|
+
descDiff.upsert.length > 0) {
|
|
85
|
+
updateMaskFields.push("description");
|
|
86
|
+
}
|
|
87
|
+
if (updateMaskFields.length === 0)
|
|
88
|
+
return observed;
|
|
89
|
+
return yield* patchProjectsServiceAccounts({
|
|
90
|
+
name: observed.name,
|
|
91
|
+
body: {
|
|
92
|
+
updateMask: updateMaskFields.join(","),
|
|
93
|
+
serviceAccount: {
|
|
94
|
+
displayName: desired.displayName,
|
|
95
|
+
description: desired.descriptionWithMarker,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
// Apply merged IAM bindings as a single setIamPolicy. Same
|
|
101
|
+
// foreign-binding preservation + etag-retry pattern as the
|
|
102
|
+
// Project's `syncIam` — see `src/CloudResourceManager/Project.ts:421-474`.
|
|
103
|
+
const syncIam = (args) => Effect.gen(function* () {
|
|
104
|
+
const desiredByRole = new Map();
|
|
105
|
+
for (const b of args.bindings) {
|
|
106
|
+
for (const ib of b.data.iamBindings) {
|
|
107
|
+
const set = desiredByRole.get(ib.role) ?? new Set();
|
|
108
|
+
for (const m of ib.members)
|
|
109
|
+
set.add(m);
|
|
110
|
+
desiredByRole.set(ib.role, set);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (desiredByRole.size === 0)
|
|
114
|
+
return;
|
|
115
|
+
const current = yield* getIamPolicyProjectsServiceAccounts({
|
|
116
|
+
resource: args.resourceName,
|
|
117
|
+
"options.requestedPolicyVersion": 3,
|
|
118
|
+
});
|
|
119
|
+
const existingBindings = (current.bindings ?? []).map((b) => ({
|
|
120
|
+
...b,
|
|
121
|
+
members: [...(b.members ?? [])],
|
|
122
|
+
}));
|
|
123
|
+
let mutated = false;
|
|
124
|
+
for (const [role, members] of desiredByRole) {
|
|
125
|
+
let existing = existingBindings.find((b) => b.role === role && !b.condition);
|
|
126
|
+
if (!existing) {
|
|
127
|
+
existing = { role, members: [] };
|
|
128
|
+
existingBindings.push(existing);
|
|
129
|
+
}
|
|
130
|
+
const merged = new Set([...(existing.members ?? []), ...members]);
|
|
131
|
+
if (merged.size !== (existing.members?.length ?? 0))
|
|
132
|
+
mutated = true;
|
|
133
|
+
existing.members = [...merged];
|
|
134
|
+
}
|
|
135
|
+
if (!mutated)
|
|
136
|
+
return;
|
|
137
|
+
yield* setIamPolicyProjectsServiceAccounts({
|
|
138
|
+
resource: args.resourceName,
|
|
139
|
+
body: {
|
|
140
|
+
policy: { ...current, bindings: existingBindings, version: 3 },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}).pipe(Effect.retry({
|
|
144
|
+
schedule: Schedule.exponential(Duration.seconds(2)).pipe(Schedule.both(Schedule.recurs(8))),
|
|
145
|
+
}));
|
|
146
|
+
return {
|
|
147
|
+
// Stable identity: accountId + project; both immutable; either
|
|
148
|
+
// change → replace. The server-assigned uniqueId is also stable
|
|
149
|
+
// for the lifetime of the GSA (re-create gets a different one).
|
|
150
|
+
stables: ["accountId", "projectId", "uniqueId", "name", "email"],
|
|
151
|
+
diff: Effect.fn(function* ({ id, news, olds = {}, output }) {
|
|
152
|
+
if (!isResolved(news))
|
|
153
|
+
return undefined;
|
|
154
|
+
if (news.accountId !== (output?.accountId ?? olds.accountId) ||
|
|
155
|
+
news.project !== (output?.projectId ?? olds.project)) {
|
|
156
|
+
return { action: "replace" };
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}),
|
|
160
|
+
reconcile: Effect.fn(function* ({ id, news, bindings }) {
|
|
161
|
+
const descriptionWithMarker = yield* gcpAlchemyDescription(id, news.description);
|
|
162
|
+
// 1. Observe — cloud state is authoritative. A second
|
|
163
|
+
// scan-by-marker covers the cold-recovery case where
|
|
164
|
+
// persisted `output`/`olds` lost the uniqueId after
|
|
165
|
+
// state loss. We prefer the direct-by-name probe first
|
|
166
|
+
// (cheap) and fall back to the scan only when direct
|
|
167
|
+
// observation returned `undefined`.
|
|
168
|
+
let observed = yield* observeServiceAccount(news.project, news.accountId);
|
|
169
|
+
if (!observed) {
|
|
170
|
+
observed = yield* findByAlchemyMarker(id, news.project);
|
|
171
|
+
}
|
|
172
|
+
// 2. Ensure — create if missing. createProjectsServiceAccounts
|
|
173
|
+
// is synchronous (returns the SA, not an LRO). Conflict
|
|
174
|
+
// covers a peer reconciler race — re-observe.
|
|
175
|
+
if (!observed) {
|
|
176
|
+
observed = yield* createProjectsServiceAccounts({
|
|
177
|
+
name: `projects/${news.project}`,
|
|
178
|
+
body: {
|
|
179
|
+
accountId: news.accountId,
|
|
180
|
+
serviceAccount: {
|
|
181
|
+
displayName: news.displayName,
|
|
182
|
+
description: descriptionWithMarker,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
}).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
|
|
186
|
+
if (!observed) {
|
|
187
|
+
observed = yield* requireServiceAccount(news.project, news.accountId, "did not appear after create");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// 3. Sync mutable fields against OBSERVED state.
|
|
191
|
+
const synced = yield* syncMutable(observed, {
|
|
192
|
+
displayName: news.displayName ?? observed.displayName,
|
|
193
|
+
descriptionWithMarker,
|
|
194
|
+
descriptionUserOnly: news.description,
|
|
195
|
+
});
|
|
196
|
+
// 4. Sync IAM bindings (Workload Identity, impersonators, …).
|
|
197
|
+
// Single setIamPolicy on the GSA with merged bindings;
|
|
198
|
+
// foreign roles preserved. Etag-retried internally.
|
|
199
|
+
yield* syncIam({
|
|
200
|
+
resourceName: synced.name,
|
|
201
|
+
bindings,
|
|
202
|
+
});
|
|
203
|
+
return toAttributes(synced);
|
|
204
|
+
}),
|
|
205
|
+
delete: Effect.fn(function* ({ output }) {
|
|
206
|
+
// Synchronous delete — Empty response, no LRO. NotFound
|
|
207
|
+
// means the GSA was already deleted (or never existed in our
|
|
208
|
+
// scope); both are idempotent successes.
|
|
209
|
+
yield* deleteProjectsServiceAccounts({
|
|
210
|
+
name: output.name,
|
|
211
|
+
}).pipe(Effect.catchTag("NotFound", () => Effect.void), Effect.catchTag("Forbidden", () => Effect.void));
|
|
212
|
+
}),
|
|
213
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
214
|
+
// Direct-by-name probe when we have any handle on the
|
|
215
|
+
// accountId. Falls back to a project-wide scan-by-marker
|
|
216
|
+
// for cold recovery after state loss.
|
|
217
|
+
const accountId = output?.accountId ?? olds?.accountId;
|
|
218
|
+
const projectId = output?.projectId ?? olds?.project;
|
|
219
|
+
let observed;
|
|
220
|
+
if (accountId && projectId) {
|
|
221
|
+
observed = yield* observeServiceAccount(projectId, accountId);
|
|
222
|
+
}
|
|
223
|
+
if (!observed && projectId) {
|
|
224
|
+
observed = yield* findByAlchemyMarker(id, projectId);
|
|
225
|
+
}
|
|
226
|
+
if (!observed)
|
|
227
|
+
return undefined;
|
|
228
|
+
const attrs = toAttributes(observed);
|
|
229
|
+
// Adoption gate via description marker (see Tags.ts §description).
|
|
230
|
+
return (yield* descriptionHasAlchemyMarker(id, observed.description))
|
|
231
|
+
? attrs
|
|
232
|
+
: Unowned(attrs);
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
}));
|
|
236
|
+
//# sourceMappingURL=ServiceAccount.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ServiceAccount.js","sourceRoot":"","sources":["../../src/Iam/ServiceAccount.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type { ServiceAccount } from "./ServiceAccount.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Bind a single `(role, member)` IAM grant onto a {@link ServiceAccount}.
|
|
5
|
+
*
|
|
6
|
+
* This is a **target-side binding** — it records the grant onto the
|
|
7
|
+
* GSA's binding bag (`stack.bindings[gsa]`); the ServiceAccount
|
|
8
|
+
* provider's `reconcile` later merges all such bindings (across all
|
|
9
|
+
* callers, including parallel ones from different stack stages) into
|
|
10
|
+
* a single `setIamPolicy` call on the GSA. Same pattern as
|
|
11
|
+
* `projectIamMember` / `subnetworkIamMember`.
|
|
12
|
+
*
|
|
13
|
+
* Identity for dedup: the rendered SID is `IamMember(<gsa>, <key>)`.
|
|
14
|
+
* The engine collapses binds by SID, so callers MUST supply a
|
|
15
|
+
* stack-unique `key` per (gsa, role, member) triple — otherwise a
|
|
16
|
+
* later bind silently overwrites an earlier one with the same key.
|
|
17
|
+
*
|
|
18
|
+
* The reason `key` exists: `member` is usually an `Output<string>`
|
|
19
|
+
* (e.g. `Output.interpolate\`...${cluster.projectId}...\``), and
|
|
20
|
+
* `Output` expressions stringify by their AST shape, not by the
|
|
21
|
+
* eventual value. Two interpolations of the same shape but different
|
|
22
|
+
* literal templates render the same SID. `key` sidesteps that by
|
|
23
|
+
* forcing a static, caller-supplied label into the SID.
|
|
24
|
+
*
|
|
25
|
+
* Use this for the "Pulumi `gcp.serviceAccount.IAMMember`" pattern:
|
|
26
|
+
* authoritative-for-(role, member)-tuple, additive (does not displace
|
|
27
|
+
* other members on the same role).
|
|
28
|
+
*
|
|
29
|
+
* @example Workload Identity binding for one cluster
|
|
30
|
+
* ```typescript
|
|
31
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterA-WorkloadIdentity", {
|
|
32
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
33
|
+
* member: `serviceAccount:${clusterA.projectId}.svc.id.goog[researchers/build]`,
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @example Same GSA, second cluster (separate key → distinct SID)
|
|
38
|
+
* ```typescript
|
|
39
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterB-WorkloadIdentity", {
|
|
40
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
41
|
+
* member: `serviceAccount:${clusterB.projectId}.svc.id.goog[researchers/build]`,
|
|
42
|
+
* });
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare const serviceAccountIamMember: (gsa: ServiceAccount, key: string, args: {
|
|
46
|
+
role: string;
|
|
47
|
+
member: string;
|
|
48
|
+
}) => Effect.Effect<void>;
|
|
49
|
+
//# sourceMappingURL=ServiceAccountIamMember.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ServiceAccountIamMember.d.ts","sourceRoot":"","sources":["../../src/Iam/ServiceAccountIamMember.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AACxC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,eAAO,MAAM,uBAAuB,GAClC,KAAK,cAAc,EACnB,KAAK,MAAM,EACX,MAAM;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,KACrC,MAAM,CAAC,MAAM,CAAC,IAAI,CAMiB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bind a single `(role, member)` IAM grant onto a {@link ServiceAccount}.
|
|
3
|
+
*
|
|
4
|
+
* This is a **target-side binding** — it records the grant onto the
|
|
5
|
+
* GSA's binding bag (`stack.bindings[gsa]`); the ServiceAccount
|
|
6
|
+
* provider's `reconcile` later merges all such bindings (across all
|
|
7
|
+
* callers, including parallel ones from different stack stages) into
|
|
8
|
+
* a single `setIamPolicy` call on the GSA. Same pattern as
|
|
9
|
+
* `projectIamMember` / `subnetworkIamMember`.
|
|
10
|
+
*
|
|
11
|
+
* Identity for dedup: the rendered SID is `IamMember(<gsa>, <key>)`.
|
|
12
|
+
* The engine collapses binds by SID, so callers MUST supply a
|
|
13
|
+
* stack-unique `key` per (gsa, role, member) triple — otherwise a
|
|
14
|
+
* later bind silently overwrites an earlier one with the same key.
|
|
15
|
+
*
|
|
16
|
+
* The reason `key` exists: `member` is usually an `Output<string>`
|
|
17
|
+
* (e.g. `Output.interpolate\`...${cluster.projectId}...\``), and
|
|
18
|
+
* `Output` expressions stringify by their AST shape, not by the
|
|
19
|
+
* eventual value. Two interpolations of the same shape but different
|
|
20
|
+
* literal templates render the same SID. `key` sidesteps that by
|
|
21
|
+
* forcing a static, caller-supplied label into the SID.
|
|
22
|
+
*
|
|
23
|
+
* Use this for the "Pulumi `gcp.serviceAccount.IAMMember`" pattern:
|
|
24
|
+
* authoritative-for-(role, member)-tuple, additive (does not displace
|
|
25
|
+
* other members on the same role).
|
|
26
|
+
*
|
|
27
|
+
* @example Workload Identity binding for one cluster
|
|
28
|
+
* ```typescript
|
|
29
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterA-WorkloadIdentity", {
|
|
30
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
31
|
+
* member: `serviceAccount:${clusterA.projectId}.svc.id.goog[researchers/build]`,
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* @example Same GSA, second cluster (separate key → distinct SID)
|
|
36
|
+
* ```typescript
|
|
37
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterB-WorkloadIdentity", {
|
|
38
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
39
|
+
* member: `serviceAccount:${clusterB.projectId}.svc.id.goog[researchers/build]`,
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const serviceAccountIamMember = (gsa, key, args) =>
|
|
44
|
+
// Tagged-template `.bind` records into the target's binding bag.
|
|
45
|
+
// ServiceAccount's reconcile sees `bindings: ResourceBinding<…>[]`
|
|
46
|
+
// and merges all entries by role into one setIamPolicy call.
|
|
47
|
+
gsa.bind `IamMember(${gsa}, ${key})`({
|
|
48
|
+
iamBindings: [{ role: args.role, members: [args.member] }],
|
|
49
|
+
});
|
|
50
|
+
//# sourceMappingURL=ServiceAccountIamMember.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ServiceAccountIamMember.js","sourceRoot":"","sources":["../../src/Iam/ServiceAccountIamMember.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CACrC,GAAmB,EACnB,GAAW,EACX,IAAsC,EACjB,EAAE;AACvB,iEAAiE;AACjE,mEAAmE;AACnE,6DAA6D;AAC7D,GAAG,CAAC,IAAI,CAAA,aAAa,GAAG,KAAK,GAAG,GAAG,CAAC;IAClC,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 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/Iam/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,YAAY,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC/D,cAAc,8BAA8B,CAAC"}
|
package/lib/Iam/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/Iam/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAE7E,cAAc,8BAA8B,CAAC"}
|
package/lib/Providers.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Providers.d.ts","sourceRoot":"","sources":["../src/Providers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;;
|
|
1
|
+
{"version":3,"file":"Providers.d.ts","sourceRoot":"","sources":["../src/Providers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,KAAK,MAAM,cAAc,CAAC;;AAmDtC,qBAAa,SAAU,SAAQ,cAA+C;CAAG;AAEjF,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,QAAQ,CAAC,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC,CAAC;AAEhF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,SAAS,8QA+DnB,CAAC"}
|
package/lib/Providers.js
CHANGED
|
@@ -14,6 +14,7 @@ import { SharedVpcServiceProject, SharedVpcServiceProjectProvider, } from "./Com
|
|
|
14
14
|
import { Subnetwork, SubnetworkProvider } from "./Compute/Subnetwork.js";
|
|
15
15
|
import { Cluster, ClusterProvider } from "./Container/Cluster.js";
|
|
16
16
|
import { NodePool, NodePoolProvider } from "./Container/NodePool.js";
|
|
17
|
+
import { ServiceAccount, ServiceAccountProvider, } from "./Iam/ServiceAccount.js";
|
|
17
18
|
import { KubernetesManifest, KubernetesManifestProvider, } from "./Kubernetes/KubernetesManifest.js";
|
|
18
19
|
import { KubernetesSecret, KubernetesSecretProvider, } from "./Kubernetes/Secret.js";
|
|
19
20
|
import { ManagedLustreInstance, ManagedLustreInstanceProvider, } from "./ManagedLustre/Instance.js";
|
|
@@ -62,9 +63,10 @@ export const providers = () => Layer.effect(Providers, Provider.collection([
|
|
|
62
63
|
SqlDatabase,
|
|
63
64
|
SqlUser,
|
|
64
65
|
ArtifactRegistryRepository,
|
|
66
|
+
ServiceAccount,
|
|
65
67
|
KubernetesSecret,
|
|
66
68
|
KubernetesManifest,
|
|
67
|
-
])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), ClusterProvider(), NodePoolProvider(), ApiEnableProvider(), NetworkProvider(), SubnetworkProvider(), FirewallProvider(), AddressProvider(), ForwardingRuleProvider(), GlobalAddressProvider(), SharedVpcHostProvider(), SharedVpcServiceProjectProvider(), PsaConnectionProvider(), ManagedLustreInstanceProvider(), ServiceProvider(), JobProvider(), SqlInstanceProvider(), SqlDatabaseProvider(), SqlUserProvider(), ArtifactRegistryRepositoryProvider(), KubernetesSecretProvider(), KubernetesManifestProvider())), Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(GCPAuth),
|
|
69
|
+
])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), ClusterProvider(), NodePoolProvider(), ApiEnableProvider(), NetworkProvider(), SubnetworkProvider(), FirewallProvider(), AddressProvider(), ForwardingRuleProvider(), GlobalAddressProvider(), SharedVpcHostProvider(), SharedVpcServiceProjectProvider(), PsaConnectionProvider(), ManagedLustreInstanceProvider(), ServiceProvider(), JobProvider(), SqlInstanceProvider(), SqlDatabaseProvider(), SqlUserProvider(), ArtifactRegistryRepositoryProvider(), ServiceAccountProvider(), KubernetesSecretProvider(), KubernetesManifestProvider())), Layer.provideMerge(fromAuthProvider()), Layer.provideMerge(GCPAuth),
|
|
68
70
|
// Auth/config failures (missing ADC, malformed profile) become
|
|
69
71
|
// unrecoverable defects rather than typed errors — mirrors
|
|
70
72
|
// `AWS/Providers.ts` and `Cloudflare/Providers.ts`. Resource lifecycle
|
package/lib/Providers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Providers.js","sourceRoot":"","sources":["../src/Providers.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Providers.js","sourceRoot":"","sources":["../src/Providers.ts"],"names":[],"mappings":"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"}
|
package/lib/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ 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";
|
package/lib/index.d.ts.map
CHANGED
|
@@ -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,gBAAgB,CAAC"}
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ 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";
|
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,gBAAgB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@microagi/alchemy-gcp",
|
|
3
|
-
"version": "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.0",
|
|
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",
|
|
@@ -0,0 +1,431 @@
|
|
|
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
|
+
if (
|
|
325
|
+
news.accountId !== (output?.accountId ?? olds.accountId) ||
|
|
326
|
+
news.project !== (output?.projectId ?? olds.project)
|
|
327
|
+
) {
|
|
328
|
+
return { action: "replace" } as const;
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}),
|
|
332
|
+
reconcile: Effect.fn(function* ({ id, news, bindings }) {
|
|
333
|
+
const descriptionWithMarker = yield* gcpAlchemyDescription(
|
|
334
|
+
id,
|
|
335
|
+
news.description,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// 1. Observe — cloud state is authoritative. A second
|
|
339
|
+
// scan-by-marker covers the cold-recovery case where
|
|
340
|
+
// persisted `output`/`olds` lost the uniqueId after
|
|
341
|
+
// state loss. We prefer the direct-by-name probe first
|
|
342
|
+
// (cheap) and fall back to the scan only when direct
|
|
343
|
+
// observation returned `undefined`.
|
|
344
|
+
let observed = yield* observeServiceAccount(news.project, news.accountId);
|
|
345
|
+
if (!observed) {
|
|
346
|
+
observed = yield* findByAlchemyMarker(id, news.project);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 2. Ensure — create if missing. createProjectsServiceAccounts
|
|
350
|
+
// is synchronous (returns the SA, not an LRO). Conflict
|
|
351
|
+
// covers a peer reconciler race — re-observe.
|
|
352
|
+
if (!observed) {
|
|
353
|
+
observed = yield* createProjectsServiceAccounts({
|
|
354
|
+
name: `projects/${news.project}`,
|
|
355
|
+
body: {
|
|
356
|
+
accountId: news.accountId,
|
|
357
|
+
serviceAccount: {
|
|
358
|
+
displayName: news.displayName,
|
|
359
|
+
description: descriptionWithMarker,
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
}).pipe(
|
|
363
|
+
Effect.catchTag("Conflict", () =>
|
|
364
|
+
Effect.succeed(undefined as iam.ServiceAccount | undefined),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (!observed) {
|
|
369
|
+
observed = yield* requireServiceAccount(
|
|
370
|
+
news.project,
|
|
371
|
+
news.accountId,
|
|
372
|
+
"did not appear after create",
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 3. Sync mutable fields against OBSERVED state.
|
|
378
|
+
const synced = yield* syncMutable(observed, {
|
|
379
|
+
displayName: news.displayName ?? observed.displayName,
|
|
380
|
+
descriptionWithMarker,
|
|
381
|
+
descriptionUserOnly: news.description,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// 4. Sync IAM bindings (Workload Identity, impersonators, …).
|
|
385
|
+
// Single setIamPolicy on the GSA with merged bindings;
|
|
386
|
+
// foreign roles preserved. Etag-retried internally.
|
|
387
|
+
yield* syncIam({
|
|
388
|
+
resourceName: synced.name!,
|
|
389
|
+
bindings,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return toAttributes(synced);
|
|
393
|
+
}),
|
|
394
|
+
delete: Effect.fn(function* ({ output }) {
|
|
395
|
+
// Synchronous delete — Empty response, no LRO. NotFound
|
|
396
|
+
// means the GSA was already deleted (or never existed in our
|
|
397
|
+
// scope); both are idempotent successes.
|
|
398
|
+
yield* deleteProjectsServiceAccounts({
|
|
399
|
+
name: output.name,
|
|
400
|
+
}).pipe(
|
|
401
|
+
Effect.catchTag("NotFound", () => Effect.void),
|
|
402
|
+
Effect.catchTag("Forbidden", () => Effect.void),
|
|
403
|
+
);
|
|
404
|
+
}),
|
|
405
|
+
read: Effect.fn(function* ({ id, output, olds }) {
|
|
406
|
+
// Direct-by-name probe when we have any handle on the
|
|
407
|
+
// accountId. Falls back to a project-wide scan-by-marker
|
|
408
|
+
// for cold recovery after state loss.
|
|
409
|
+
const accountId =
|
|
410
|
+
output?.accountId ?? olds?.accountId;
|
|
411
|
+
const projectId =
|
|
412
|
+
output?.projectId ?? olds?.project;
|
|
413
|
+
|
|
414
|
+
let observed: iam.ServiceAccount | undefined;
|
|
415
|
+
if (accountId && projectId) {
|
|
416
|
+
observed = yield* observeServiceAccount(projectId, accountId);
|
|
417
|
+
}
|
|
418
|
+
if (!observed && projectId) {
|
|
419
|
+
observed = yield* findByAlchemyMarker(id, projectId);
|
|
420
|
+
}
|
|
421
|
+
if (!observed) return undefined;
|
|
422
|
+
|
|
423
|
+
const attrs = toAttributes(observed);
|
|
424
|
+
// Adoption gate via description marker (see Tags.ts §description).
|
|
425
|
+
return (yield* descriptionHasAlchemyMarker(id, observed.description))
|
|
426
|
+
? attrs
|
|
427
|
+
: Unowned(attrs);
|
|
428
|
+
}),
|
|
429
|
+
};
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type { ServiceAccount } from "./ServiceAccount.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bind a single `(role, member)` IAM grant onto a {@link ServiceAccount}.
|
|
6
|
+
*
|
|
7
|
+
* This is a **target-side binding** — it records the grant onto the
|
|
8
|
+
* GSA's binding bag (`stack.bindings[gsa]`); the ServiceAccount
|
|
9
|
+
* provider's `reconcile` later merges all such bindings (across all
|
|
10
|
+
* callers, including parallel ones from different stack stages) into
|
|
11
|
+
* a single `setIamPolicy` call on the GSA. Same pattern as
|
|
12
|
+
* `projectIamMember` / `subnetworkIamMember`.
|
|
13
|
+
*
|
|
14
|
+
* Identity for dedup: the rendered SID is `IamMember(<gsa>, <key>)`.
|
|
15
|
+
* The engine collapses binds by SID, so callers MUST supply a
|
|
16
|
+
* stack-unique `key` per (gsa, role, member) triple — otherwise a
|
|
17
|
+
* later bind silently overwrites an earlier one with the same key.
|
|
18
|
+
*
|
|
19
|
+
* The reason `key` exists: `member` is usually an `Output<string>`
|
|
20
|
+
* (e.g. `Output.interpolate\`...${cluster.projectId}...\``), and
|
|
21
|
+
* `Output` expressions stringify by their AST shape, not by the
|
|
22
|
+
* eventual value. Two interpolations of the same shape but different
|
|
23
|
+
* literal templates render the same SID. `key` sidesteps that by
|
|
24
|
+
* forcing a static, caller-supplied label into the SID.
|
|
25
|
+
*
|
|
26
|
+
* Use this for the "Pulumi `gcp.serviceAccount.IAMMember`" pattern:
|
|
27
|
+
* authoritative-for-(role, member)-tuple, additive (does not displace
|
|
28
|
+
* other members on the same role).
|
|
29
|
+
*
|
|
30
|
+
* @example Workload Identity binding for one cluster
|
|
31
|
+
* ```typescript
|
|
32
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterA-WorkloadIdentity", {
|
|
33
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
34
|
+
* member: `serviceAccount:${clusterA.projectId}.svc.id.goog[researchers/build]`,
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example Same GSA, second cluster (separate key → distinct SID)
|
|
39
|
+
* ```typescript
|
|
40
|
+
* yield* GCP.serviceAccountIamMember(gsa, "ClusterB-WorkloadIdentity", {
|
|
41
|
+
* role: "roles/iam.workloadIdentityUser",
|
|
42
|
+
* member: `serviceAccount:${clusterB.projectId}.svc.id.goog[researchers/build]`,
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export const serviceAccountIamMember = (
|
|
47
|
+
gsa: ServiceAccount,
|
|
48
|
+
key: string,
|
|
49
|
+
args: { role: string; member: string },
|
|
50
|
+
): Effect.Effect<void> =>
|
|
51
|
+
// Tagged-template `.bind` records into the target's binding bag.
|
|
52
|
+
// ServiceAccount's reconcile sees `bindings: ResourceBinding<…>[]`
|
|
53
|
+
// and merges all entries by role into one setIamPolicy call.
|
|
54
|
+
gsa.bind`IamMember(${gsa}, ${key})`({
|
|
55
|
+
iamBindings: [{ role: args.role, members: [args.member] }],
|
|
56
|
+
}) as unknown as Effect.Effect<void>;
|
package/src/Iam/index.ts
ADDED
package/src/Providers.ts
CHANGED
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
import { Subnetwork, SubnetworkProvider } from "./Compute/Subnetwork.ts";
|
|
24
24
|
import { Cluster, ClusterProvider } from "./Container/Cluster.ts";
|
|
25
25
|
import { NodePool, NodePoolProvider } from "./Container/NodePool.ts";
|
|
26
|
+
import {
|
|
27
|
+
ServiceAccount,
|
|
28
|
+
ServiceAccountProvider,
|
|
29
|
+
} from "./Iam/ServiceAccount.ts";
|
|
26
30
|
import {
|
|
27
31
|
KubernetesManifest,
|
|
28
32
|
KubernetesManifestProvider,
|
|
@@ -89,6 +93,7 @@ export const providers = () =>
|
|
|
89
93
|
SqlDatabase,
|
|
90
94
|
SqlUser,
|
|
91
95
|
ArtifactRegistryRepository,
|
|
96
|
+
ServiceAccount,
|
|
92
97
|
KubernetesSecret,
|
|
93
98
|
KubernetesManifest,
|
|
94
99
|
]),
|
|
@@ -115,6 +120,7 @@ export const providers = () =>
|
|
|
115
120
|
SqlDatabaseProvider(),
|
|
116
121
|
SqlUserProvider(),
|
|
117
122
|
ArtifactRegistryRepositoryProvider(),
|
|
123
|
+
ServiceAccountProvider(),
|
|
118
124
|
KubernetesSecretProvider(),
|
|
119
125
|
KubernetesManifestProvider(),
|
|
120
126
|
),
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export * from "./ArtifactRegistry/index.ts";
|
|
|
11
11
|
export * from "./CloudResourceManager/index.ts";
|
|
12
12
|
export * from "./Compute/index.ts";
|
|
13
13
|
export * from "./Container/index.ts";
|
|
14
|
+
export * from "./Iam/index.ts";
|
|
14
15
|
export * from "./Kubernetes/index.ts";
|
|
15
16
|
export * from "./ManagedLustre/index.ts";
|
|
16
17
|
export * from "./Run/index.ts";
|