1import * as storagetransfer from "@distilled.cloud/gcp/storagetransfer_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 agentPoolIdOf,
13 agentPoolName,
14 createOwnership,
15 DEFAULT_AGENT_POOL,
16 encodeOwnership,
17 fieldMask,
18 MAX_AGENT_POOL_DISPLAY_NAME,
19 getAgentPool,
20 hasOwnershipMarker,
21 listAgentPools,
22 ownedByAlchemy,
23 parseOwnership,
24 retryApiDisabled,
25 sameValue,
26 sanitizeAgentPoolId,
27 toAgentPoolId,
28 waitAgentPoolCreated,
29 waitAgentPoolGone,
30} from "./internal.ts";
31
32export type BandwidthLimit = {
33 /**
34 * Bandwidth rate in megabytes per second, distributed across all
35 * agents in the pool. Omit for no limit.
36 */
37 limitMbps?: string;
38};
39
40export type AgentPoolProps = {
41 /**
42 * Agent pool id (the `{agent_pool}` segment of
43 * `projects/{project}/agentPools/{agent_pool}`). If omitted, a unique
44 * name is generated from the stack, stage, and logical id. Must match
45 * `^(?!goog)[a-z]([a-z0-9-._~]*[a-z0-9])?$` and is at most 128
46 * characters. Immutable — changing it replaces the pool.
47 */
48 agentPoolId?: string;
49 /**
50 * Client-specified description (combined with Alchemy ownership, max
51 * 127 characters). Agent pools have no labels field, so ownership is
52 * stored in a `[alchemy …]` prefix and stripped from attributes.
53 */
54 displayName?: string;
55 /**
56 * Bandwidth cap for the pool. Unspecified means no limit.
57 */
58 bandwidthLimit?: BandwidthLimit;
59};
60
61export type AgentPool = Resource<
62 "GCP.StorageTransfer.AgentPool",
63 AgentPoolProps,
64 {
65 /** Full resource name `projects/{project}/agentPools/{agent_pool}`. */
66 name: string;
67 /** Agent pool id (last path segment). */
68 agentPoolId: string;
69 /** Project id. */
70 project: string;
71 /** User display name with the Alchemy ownership prefix stripped. */
72 displayName: string | undefined;
73 /** Pool state (`CREATING`, `CREATED`, `DELETING`). */
74 state: string | undefined;
75 /** Configured bandwidth limit, if any. */
76 bandwidthLimit: BandwidthLimit | undefined;
77 },
78 never,
79 Providers
80>;
81
82/**
83 * A Storage Transfer Service agent pool for on-premises (POSIX) agents.
84 *
85 * Agent pools have no labels field, so Alchemy stamps ownership into
86 * `displayName` for `list` / nuke. Name is identity — changing
87 * `agentPoolId` replaces the pool. `displayName` and `bandwidthLimit`
88 * update in place.
89 *
90 * ### Creating an Agent Pool
91 * **Example:** Generated id
92 * ```typescript
93 * const pool = yield* GCP.StorageTransfer.AgentPool("OnPrem", {
94 * displayName: "warehouse scanners",
95 * });
96 * ```
97 *
98 * **Example:** Named pool with a bandwidth cap
99 * ```typescript
100 * const pool = yield* GCP.StorageTransfer.AgentPool("OnPrem", {
101 * agentPoolId: "warehouse-scanners",
102 * displayName: "warehouse scanners",
103 * bandwidthLimit: { limitMbps: "120" },
104 * });
105 * ```
106 *
107 * ### Updating an Agent Pool
108 * Change props on the same logical id; the engine keeps the physical id.
109 *
110 * **Example:** Raise the bandwidth cap
111 * ```typescript
112 * const pool = yield* GCP.StorageTransfer.AgentPool("OnPrem", {
113 * displayName: "warehouse scanners",
114 * bandwidthLimit: { limitMbps: "250" },
115 * });
116 * ```
117 *
118 * @resource
119 * @category StorageTransfer
120 */
121export const AgentPool = Resource<AgentPool>("GCP.StorageTransfer.AgentPool");
122
123export class AgentPoolNotResolved extends Data.TaggedError(
124 "GCP.StorageTransfer.AgentPoolNotResolved",
125)<{
126 name: string;
127}> {}
128
129const toBandwidth = (
130 limit: storagetransfer.BandwidthLimit | BandwidthLimit | undefined,
131): BandwidthLimit | undefined => {
132 const limitMbps = limit?.limitMbps;
133 if (limitMbps === undefined || limitMbps.length === 0) return undefined;
134 return { limitMbps };
135};
136
137const toAttrs = (pool: storagetransfer.AgentPool, project: string) => {
138 const name = pool.name ?? "";
139 return {
140 name,
141 agentPoolId: agentPoolIdOf(name),
142 project,
143 displayName: parseOwnership(pool.displayName).text,
144 state: pool.state,
145 bandwidthLimit: toBandwidth(pool.bandwidthLimit),
146 };
147};
148
149export const AgentPoolProvider = () =>
150 Provider.succeed(AgentPool, {
151 stables: ["name", "agentPoolId", "project"],
152
153 diff: Effect.fn(function* ({ news, olds, output }) {
154 if (!isResolved(news)) return undefined;
155 const previous = olds?.agentPoolId ?? output?.agentPoolId;
156 if (
157 previous !== undefined &&
158 news.agentPoolId !== undefined &&
159 sanitizeAgentPoolId(news.agentPoolId) !== previous
160 ) {
161 return { action: "replace" as const, deleteFirst: false };
162 }
163 return undefined;
164 }),
165
166 read: Effect.fn(function* ({ id, olds, output }) {
167 const env = yield* GcpEnvironment.current;
168 const agentPoolId = yield* toAgentPoolId(
169 id,
170 olds?.agentPoolId,
171 output?.agentPoolId,
172 );
173 const name = output?.name ?? agentPoolName(env.project, agentPoolId);
174 const existing = yield* getAgentPool(name);
175 if (existing === undefined) return undefined;
176 const attrs = toAttrs(existing, env.project);
177 return (yield* ownedByAlchemy(id, existing.displayName))
178 ? attrs
179 : Unowned(attrs);
180 }),
181
182 list: () =>
183 Effect.gen(function* () {
184 const env = yield* GcpEnvironment.current;
185 const pools = yield* listAgentPools(env.project);
186 return pools
187 .filter(
188 (pool) =>
189 agentPoolIdOf(pool.name ?? "") !== DEFAULT_AGENT_POOL &&
190 hasOwnershipMarker(pool.displayName),
191 )
192 .map((pool) => toAttrs(pool, env.project));
193 }),
194
195 reconcile: Effect.fn(function* ({ id, news, output }) {
196 const env = yield* GcpEnvironment.current;
197 const agentPoolId = yield* toAgentPoolId(
198 id,
199 news.agentPoolId,
200 output?.agentPoolId,
201 );
202 const name = agentPoolName(env.project, agentPoolId);
203 const ownership = yield* createOwnership(id);
204 const desiredDisplayName = encodeOwnership(
205 ownership,
206 news.displayName,
207 MAX_AGENT_POOL_DISPLAY_NAME,
208 );
209 const desiredLimit = toBandwidth(news.bandwidthLimit);
210
211 let current = yield* getAgentPool(output?.name ?? name);
212 if (current?.state === "DELETING") {
213 yield* waitAgentPoolGone(name);
214 current = undefined;
215 }
216
217 if (current === undefined) {
218 const created = yield* retryApiDisabled(
219 storagetransfer.createProjectsAgentPools({
220 projectId: env.project,
221 agentPoolId,
222 body: {
223 displayName: desiredDisplayName,
224 bandwidthLimit: desiredLimit,
225 },
226 }),
227 ).pipe(Effect.catchTag("Conflict", () => getAgentPool(name)));
228 current = created ?? undefined;
229 }
230
231 if (current !== undefined && current.state === "CREATING") {
232 current = (yield* waitAgentPoolCreated(name)) ?? current;
233 }
234
235 if (current === undefined) {
236 return yield* new AgentPoolNotResolved({ name });
237 }
238
239 const displayChanged = (current.displayName ?? "") !== desiredDisplayName;
240 const limitChanged = !sameValue(
241 toBandwidth(current.bandwidthLimit),
242 desiredLimit,
243 );
244 const updateMask = fieldMask([
245 displayChanged ? "display_name" : undefined,
246 limitChanged ? "bandwidth_limit" : undefined,
247 ]);
248
249 if (updateMask.length > 0) {
250 current = yield* storagetransfer.patchProjectsAgentPools({
251 name: current.name ?? name,
252 updateMask,
253 body: {
254 displayName: desiredDisplayName,
255 bandwidthLimit: desiredLimit,
256 },
257 });
258 }
259
260 return toAttrs(current, env.project);
261 }),
262
263 delete: Effect.fn(function* ({ output }) {
264 if (output.agentPoolId === DEFAULT_AGENT_POOL) return;
265 yield* storagetransfer
266 .deleteProjectsAgentPools({ name: output.name })
267 .pipe(
268 Effect.catchTag("NotFound", () => Effect.void),
269 Effect.retry({
270 while: (error) => error._tag === "Conflict",
271 times: 8,
272 schedule: Schedule.exponential("500 millis"),
273 }),
274 );
275 }),
276 });