1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 DEFAULT_CHANNEL_PROFILE,
14 encodeOwnershipLine,
15 expandApp,
16 forEachApp,
17 hasOwnershipMarker,
18 normalizeLocation,
19 ownedByAlchemy,
20 parseOwnership,
21 parseResourceName,
22 replaceOnIdentity,
23 retryTransient,
24 sameJson,
25 sameText,
26 toPhysicalId,
27 updateMaskOf,
28 waitUntilGone,
29} from "./internal.ts";
30
31export type AppsDeploymentProps = {
32 /**
33 * Parent CES app. Full name
34 * `projects/{project}/locations/{location}/apps/{app}` or the app id
35 * (combined with `location`). Immutable — changing it replaces the
36 * deployment.
37 */
38 app: string;
39 /**
40 * Region used when `app` is a bare id.
41 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
42 */
43 location?: string;
44 /**
45 * Deployment id. If omitted, a unique name is generated. Immutable —
46 * changing it replaces the deployment.
47 */
48 deploymentId?: string;
49 /**
50 * Human-readable name. Deployments have no description or labels
51 * field, so Alchemy stamps ownership into `displayName`.
52 */
53 displayName?: string;
54 /**
55 * Channel profile used by the deployment. Defaults to an API channel.
56 */
57 channelProfile?: ces.ChannelProfile;
58 /**
59 * App version to deploy (`.../apps/{app}/versions/{version}`). The API
60 * rejects the `-` draft alias, so pin a snapshot from `GCP.CES.AppsVersion`.
61 */
62 appVersion: string;
63 /**
64 * Experiment configuration.
65 */
66 experimentConfig?: ces.ExperimentConfig;
67};
68
69export type AppsDeployment = Resource<
70 "GCP.CES.AppsDeployment",
71 AppsDeploymentProps,
72 {
73 /** Full resource name `.../apps/{app}/deployments/{deployment}`. */
74 name: string;
75 /** Deployment id (last path segment). */
76 deploymentId: string;
77 /** Parent app resource name. */
78 app: string;
79 /** Location id. */
80 location: string;
81 /** Project id. */
82 project: string;
83 /** User display name with the Alchemy ownership prefix stripped. */
84 displayName: string | undefined;
85 /** Channel profile. */
86 channelProfile: ces.ChannelProfile | undefined;
87 /** Deployed app version. */
88 appVersion: string | undefined;
89 /** Experiment configuration. */
90 experimentConfig: ces.ExperimentConfig | undefined;
91 /** RFC3339 creation timestamp. */
92 createTime: string | undefined;
93 /** RFC3339 last-update timestamp. */
94 updateTime: string | undefined;
95 /** Server-assigned etag. */
96 etag: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A Customer Engagement Suite deployment — an immutable, queryable
104 * channel binding of an app version.
105 *
106 * Deployments have no labels or description field — Alchemy stamps
107 * ownership into `displayName` so `list` / nuke can find them. Parent
108 * app, location, and deployment id are immutable.
109 *
110 * ### Creating a Deployment
111 * **Example:** App version on the API channel
112 * ```typescript
113 * const version = yield* GCP.CES.AppsVersion("V1", { app: app.name });
114 * const deployment = yield* GCP.CES.AppsDeployment("Prod", {
115 * app: app.name,
116 * appVersion: version.name,
117 * displayName: "prod",
118 * channelProfile: { channelType: "API", profileId: "api" },
119 * });
120 * ```
121 *
122 * @resource
123 * @category CES
124 */
125export const AppsDeployment = Resource<AppsDeployment>(
126 "GCP.CES.AppsDeployment",
127);
128
129export class AppsDeploymentNotResolved extends Data.TaggedError(
130 "GCP.CES.AppsDeploymentNotResolved",
131)<{
132 name: string;
133}> {}
134
135const resourceName = (app: string, deploymentId: string) =>
136 `${app}/deployments/${deploymentId}`;
137
138const toAttrs = (
139 deployment: ces.Deployment,
140 project: string,
141 region: string,
142 appHint?: string,
143) => {
144 const name = deployment.name ?? "";
145 const parsed = parseResourceName(name, "deployments", region);
146 return {
147 name,
148 deploymentId: parsed.id,
149 app: name.includes("/deployments/")
150 ? parsed.app
151 : (appHint ?? parsed.parent),
152 location: parsed.location,
153 project: parsed.project || project,
154 displayName: parseOwnership(deployment.displayName).text,
155 channelProfile: deployment.channelProfile,
156 appVersion: deployment.appVersion,
157 experimentConfig: deployment.experimentConfig,
158 createTime: deployment.createTime,
159 updateTime: deployment.updateTime,
160 etag: deployment.etag,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0
166 ? Effect.succeed(undefined)
167 : ces
168 .getProjectsLocationsAppsDeployments({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listAt = (parent: string, project: string, region: string) =>
172 collectPages(
173 ces.listProjectsLocationsAppsDeployments.pages({
174 parent,
175 pageSize: 100,
176 }),
177 (page) => page.deployments,
178 ).pipe(
179 Effect.map((deployments) =>
180 deployments
181 .filter((deployment) => hasOwnershipMarker(deployment.displayName))
182 .map((deployment) => toAttrs(deployment, project, region, parent)),
183 ),
184 );
185
186export const AppsDeploymentProvider = () =>
187 Provider.succeed(AppsDeployment, {
188 stables: [
189 "name",
190 "deploymentId",
191 "app",
192 "location",
193 "project",
194 "createTime",
195 ],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 return replaceOnIdentity({
200 previousId: olds?.deploymentId ?? output?.deploymentId,
201 nextId: news.deploymentId,
202 previousParent: olds?.app ?? output?.app,
203 nextParent: news.app,
204 });
205 }),
206
207 read: Effect.fn(function* ({ id, olds, output }) {
208 const env = yield* GcpEnvironment.current;
209 const location = normalizeLocation(
210 olds?.location ?? output?.location,
211 env.region,
212 );
213 const app = olds?.app
214 ? expandApp(olds.app, env.project, location)
215 : output?.app;
216 const deploymentId = yield* toPhysicalId(
217 id,
218 olds?.deploymentId,
219 output?.deploymentId,
220 );
221 const name =
222 output?.name ??
223 (app !== undefined ? resourceName(app, deploymentId) : "");
224 const existing = yield* getByName(name);
225 if (existing === undefined) return undefined;
226 const attrs = toAttrs(existing, env.project, env.region, app);
227 return (yield* ownedByAlchemy(id, existing.displayName))
228 ? attrs
229 : Unowned(attrs);
230 }),
231
232 list: () =>
233 Effect.gen(function* () {
234 const env = yield* GcpEnvironment.current;
235 return yield* forEachApp(
236 env.project,
237 (parent) => listAt(parent, env.project, env.region),
238 env.region,
239 );
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const env = yield* GcpEnvironment.current;
244 const location = normalizeLocation(
245 news.location ?? output?.location,
246 env.region,
247 );
248 const app = expandApp(news.app, env.project, location);
249 const deploymentId = yield* toPhysicalId(
250 id,
251 news.deploymentId,
252 output?.deploymentId,
253 );
254 const name = output?.name ?? resourceName(app, deploymentId);
255 const ownership = yield* createInternalLabels(id);
256 const displayName = encodeOwnershipLine(ownership, news.displayName);
257 const channelProfile = news.channelProfile ?? DEFAULT_CHANNEL_PROFILE;
258 const appVersion = news.appVersion;
259
260 let current = yield* getByName(name);
261
262 if (current === undefined) {
263 const created = yield* retryTransient(
264 ces.createProjectsLocationsAppsDeployments({
265 parent: app,
266 deploymentId,
267 body: {
268 displayName,
269 channelProfile,
270 appVersion,
271 experimentConfig: news.experimentConfig,
272 },
273 }),
274 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
275 current = created ?? undefined;
276 }
277
278 if (current === undefined) {
279 return yield* new AppsDeploymentNotResolved({ name });
280 }
281
282 const currentName = current.name ?? name;
283 const displayChanged = !sameText(current.displayName, displayName);
284 const channelChanged = !sameJson(current.channelProfile, channelProfile);
285 const versionChanged = !sameText(current.appVersion, appVersion);
286 const experimentChanged = !sameJson(
287 current.experimentConfig,
288 news.experimentConfig,
289 );
290
291 if (
292 displayChanged ||
293 channelChanged ||
294 versionChanged ||
295 experimentChanged
296 ) {
297 current = yield* retryTransient(
298 ces.patchProjectsLocationsAppsDeployments({
299 name: currentName,
300 updateMask: updateMaskOf(
301 displayChanged ? "display_name" : undefined,
302 channelChanged ? "channel_profile" : undefined,
303 versionChanged ? "app_version" : undefined,
304 experimentChanged ? "experiment_config" : undefined,
305 ),
306 body: {
307 displayName,
308 channelProfile,
309 appVersion,
310 experimentConfig: news.experimentConfig,
311 },
312 }),
313 );
314 }
315
316 return toAttrs(current, env.project, env.region, app);
317 }),
318
319 delete: Effect.fn(function* ({ output }) {
320 if (!output.name) return;
321 yield* retryTransient(
322 ces.deleteProjectsLocationsAppsDeployments({ name: output.name }),
323 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
324 yield* waitUntilGone(getByName(output.name));
325 }),
326 });