1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import {
22 environmentIdOf,
23 environmentNameOf,
24 lastSegment,
25 listProjectEnvironments,
26 missingToUndefined,
27 organizationIdOf,
28 parseOrgEnv,
29 toResourceId,
30} from "./common.ts";
31import { waitForOperation } from "./operations.ts";
32
33const MAX_NAME_LENGTH = 63;
34
35export type EnvironmentsArchiveDeploymentProps = {
36 /**
37 * Apigee organization id or `organizations/{org}`. Defaults to the
38 * current GCP project id. Immutable — changing it replaces the
39 * deployment.
40 */
41 organization?: string;
42 /**
43 * Environment id or `organizations/{org}/environments/{env}`. The
44 * environment must use archive deployment. Immutable — changing it
45 * replaces the deployment.
46 */
47 environment: string;
48 /**
49 * Archive deployment id (last path segment). If omitted, a unique name
50 * is generated. Immutable — changing it replaces the deployment.
51 */
52 archiveDeploymentId?: string;
53 /**
54 * Signed GCS URI from `generateUploadUrl`, or a `gs://` object that
55 * already holds the archive zip. Input-only.
56 */
57 gcsUri?: string;
58 /**
59 * Base64-encoded archive zip. When `gcsUri` is omitted, Alchemy calls
60 * generateUploadUrl and PUTs these bytes to the signed URL.
61 */
62 archiveZip?: string;
63 /**
64 * User labels. Alchemy ownership labels are merged in automatically.
65 */
66 labels?: Record<string, string>;
67};
68
69export type EnvironmentsArchiveDeployment = Resource<
70 "GCP.Apigee.EnvironmentsArchiveDeployment",
71 EnvironmentsArchiveDeploymentProps,
72 {
73 /** Full resource name `organizations/{org}/environments/{env}/archiveDeployments/{id}`. */
74 name: string;
75 /** Archive deployment id. */
76 archiveDeploymentId: string;
77 /** Apigee organization id. */
78 organizationId: string;
79 /** Environment id. */
80 environmentId: string;
81 /** User labels (Alchemy ownership labels stripped). */
82 labels: Record<string, string>;
83 /** Create LRO name, if still reported. */
84 operation: string | undefined;
85 /** Create time in milliseconds since epoch. */
86 createdAt: string | undefined;
87 /** Update time in milliseconds since epoch. */
88 updatedAt: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * An Apigee archive deployment — a zip of API proxies deployed as a unit
96 * to an archive-mode environment.
97 *
98 * Labels carry Alchemy ownership so `list` / nuke can find leaked rows.
99 * Name is identity; labels update in place. Creating an archive is a
100 * long-running operation.
101 *
102 * ### Creating an Archive Deployment
103 * **Example:** Upload a zip
104 * ```typescript
105 * const archive = yield* GCP.Apigee.EnvironmentsArchiveDeployment("Bundle", {
106 * environment: "eval",
107 * archiveZip: zipBase64,
108 * labels: { env: "test" },
109 * });
110 * ```
111 *
112 * @resource
113 * @category Apigee
114 */
115export const EnvironmentsArchiveDeployment =
116 Resource<EnvironmentsArchiveDeployment>(
117 "GCP.Apigee.EnvironmentsArchiveDeployment",
118 );
119
120export class EnvironmentsArchiveDeploymentNotResolved extends Data.TaggedError(
121 "GCP.Apigee.EnvironmentsArchiveDeploymentNotResolved",
122)<{
123 name: string;
124}> {}
125
126const resourceName = (
127 organizationId: string,
128 environmentId: string,
129 archiveDeploymentId: string,
130) =>
131 `${environmentNameOf(organizationId, environmentId)}/archiveDeployments/${archiveDeploymentId}`;
132
133const userLabels = (
134 labels: Record<string, string | undefined> | null | undefined,
135): Record<string, string> => stripInternalLabels(tagRecord(labels));
136
137const toAttrs = (
138 archive: apigee.GoogleCloudApigeeV1ArchiveDeployment,
139 organizationId: string,
140 environmentId: string,
141) => {
142 const raw = archive.name ?? "";
143 const parsed = parseOrgEnv(raw);
144 const archiveDeploymentId = lastSegment(raw);
145 return {
146 name: raw.includes("/")
147 ? raw
148 : resourceName(organizationId, environmentId, archiveDeploymentId || raw),
149 archiveDeploymentId: archiveDeploymentId || raw,
150 organizationId: parsed.organizationId || organizationId,
151 environmentId: parsed.environmentId || environmentId,
152 labels: userLabels(archive.labels),
153 operation: archive.operation,
154 createdAt: archive.createdAt,
155 updatedAt: archive.updatedAt,
156 };
157};
158
159const getByName = (name: string) =>
160 missingToUndefined(
161 apigee.getOrganizationsEnvironmentsArchiveDeployments({ name }),
162 );
163
164const uploadZip = (uploadUri: string, archiveZip: string) =>
165 Effect.gen(function* () {
166 const client = yield* HttpClient.HttpClient;
167 const bytes = yield* Effect.sync(() => Buffer.from(archiveZip, "base64"));
168 const response = yield* client.execute(
169 HttpClientRequest.put(uploadUri).pipe(
170 HttpClientRequest.bodyUint8Array(bytes, "application/zip"),
171 ),
172 );
173 if (response.status >= 400) {
174 return yield* new EnvironmentsArchiveDeploymentNotResolved({
175 name: uploadUri,
176 });
177 }
178 return uploadUri;
179 });
180
181export const EnvironmentsArchiveDeploymentProvider = () =>
182 Provider.succeed(EnvironmentsArchiveDeployment, {
183 stables: [
184 "name",
185 "archiveDeploymentId",
186 "organizationId",
187 "environmentId",
188 "createdAt",
189 ],
190
191 diff: Effect.fn(function* ({ news, olds, output }) {
192 if (!isResolved(news)) return undefined;
193 const previousId =
194 olds?.archiveDeploymentId ?? output?.archiveDeploymentId;
195 const previousOrg = olds?.organization ?? output?.organizationId;
196 const previousEnv = olds?.environment ?? output?.environmentId;
197 const idChanged =
198 previousId !== undefined &&
199 news.archiveDeploymentId !== undefined &&
200 news.archiveDeploymentId !== previousId;
201 const orgChanged =
202 previousOrg !== undefined &&
203 news.organization !== undefined &&
204 organizationIdOf(news.organization, "") !==
205 organizationIdOf(previousOrg, "");
206 const envChanged =
207 previousEnv !== undefined &&
208 environmentIdOf(news.environment) !== environmentIdOf(previousEnv);
209 if (idChanged || orgChanged || envChanged) {
210 return { action: "replace" as const, deleteFirst: false };
211 }
212 return undefined;
213 }),
214
215 read: Effect.fn(function* ({ id, olds, output }) {
216 const { project } = yield* GcpEnvironment.current;
217 const organizationId = organizationIdOf(
218 olds?.organization ?? output?.organizationId,
219 project,
220 );
221 const environmentId = environmentIdOf(
222 olds?.environment ?? output?.environmentId ?? "",
223 );
224 const archiveDeploymentId = yield* toResourceId(
225 id,
226 olds?.archiveDeploymentId,
227 output?.archiveDeploymentId,
228 { maxLength: MAX_NAME_LENGTH, rfc1035: true },
229 );
230 const name =
231 output?.name ??
232 resourceName(organizationId, environmentId, archiveDeploymentId);
233 const existing = yield* getByName(name);
234 if (existing === undefined) return undefined;
235 const attrs = toAttrs(existing, organizationId, environmentId);
236 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
237 ? attrs
238 : Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const environments = yield* listProjectEnvironments();
244 const found: EnvironmentsArchiveDeployment["Attributes"][] = [];
245 for (const item of environments) {
246 const archives =
247 yield* apigee.listOrganizationsEnvironmentsArchiveDeployments
248 .pages({ parent: item.parent, pageSize: 100 })
249 .pipe(
250 Stream.flatMap((page) =>
251 Stream.fromIterable(page.archiveDeployments ?? []),
252 ),
253 Stream.filter((archive) =>
254 Object.keys(archive.labels ?? {}).some((key) =>
255 key.startsWith("alchemy-"),
256 ),
257 ),
258 Stream.map((archive) =>
259 toAttrs(archive, item.organizationId, item.environmentId),
260 ),
261 Stream.runCollect,
262 Effect.map((chunk) => Array.from(chunk)),
263 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
264 Effect.succeed(
265 [] as EnvironmentsArchiveDeployment["Attributes"][],
266 ),
267 ),
268 );
269 found.push(...archives);
270 }
271 return found;
272 }),
273
274 reconcile: Effect.fn(function* ({ id, news, output }) {
275 const { project } = yield* GcpEnvironment.current;
276 const organizationId = organizationIdOf(news.organization, project);
277 const environmentId = environmentIdOf(news.environment);
278 const archiveDeploymentId = yield* toResourceId(
279 id,
280 news.archiveDeploymentId,
281 output?.archiveDeploymentId,
282 { maxLength: MAX_NAME_LENGTH, rfc1035: true },
283 );
284 const parent = environmentNameOf(organizationId, environmentId);
285 const name = resourceName(
286 organizationId,
287 environmentId,
288 archiveDeploymentId,
289 );
290 const desiredLabels = {
291 ...toLabels(news.labels),
292 ...(yield* createInternalLabels(id)),
293 };
294
295 let current = yield* getByName(output?.name ?? name);
296
297 if (current === undefined) {
298 let gcsUri = news.gcsUri;
299 if (gcsUri === undefined && news.archiveZip !== undefined) {
300 const upload =
301 yield* apigee.generateUploadUrlOrganizationsEnvironmentsArchiveDeployments(
302 { parent, body: {} },
303 );
304 if (upload.uploadUri) {
305 gcsUri = yield* uploadZip(upload.uploadUri, news.archiveZip);
306 }
307 }
308 const operation = yield* apigee
309 .createOrganizationsEnvironmentsArchiveDeployments({
310 parent,
311 body: {
312 name,
313 gcsUri,
314 labels: desiredLabels,
315 },
316 })
317 .pipe(
318 Effect.catchTag("Conflict", () =>
319 Effect.succeed<apigee.GoogleLongrunningOperation>({
320 done: true,
321 }),
322 ),
323 );
324 if (operation.name || operation.done !== true) {
325 yield* waitForOperation(operation);
326 }
327 current = yield* getByName(name);
328 }
329
330 if (current === undefined) {
331 return yield* new EnvironmentsArchiveDeploymentNotResolved({ name });
332 }
333
334 const observedLabels = tagRecord(current.labels);
335 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
336 if (upsert.length > 0 || removed.length > 0) {
337 current =
338 yield* apigee.patchOrganizationsEnvironmentsArchiveDeployments({
339 name: current.name ?? name,
340 updateMask: "labels",
341 body: { labels: desiredLabels },
342 });
343 }
344
345 return toAttrs(current, organizationId, environmentId);
346 }),
347
348 delete: Effect.fn(function* ({ output }) {
349 yield* apigee
350 .deleteOrganizationsEnvironmentsArchiveDeployments({
351 name: output.name,
352 })
353 .pipe(
354 Effect.catchTag(
355 ["NotFound", "ApigeeResourceNotFound"],
356 () => Effect.void,
357 ),
358 );
359 }),
360 });