1import * as dataproc from "@distilled.cloud/gcp/dataproc_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 collectPages,
19 LIST_LOCATIONS,
20 MAX_SESSION_TEMPLATE_ID_LENGTH,
21 emptyOnMissing,
22 fingerprint,
23 hasAlchemyLabelMap,
24 locationParent,
25 normalizeLocation,
26 parseResourceName,
27 toPhysicalId,
28 userLabels,
29 waitUntilGone,
30} from "./internal.ts";
31
32export type SessionTemplateProps = {
33 /**
34 * Template id (the `{template}` segment of
35 * `projects/{project}/locations/{location}/sessionTemplates/{template}`).
36 * If omitted, a unique RFC1035 name is generated. Immutable — changing
37 * it replaces the template.
38 */
39 templateId?: string;
40 /**
41 * Location (`us-central1`, …). Immutable — changing it replaces the
42 * template. `US-CENTRAL1` is accepted and normalized to `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
44 */
45 location?: string;
46 /**
47 * User labels. Alchemy ownership labels are merged in automatically.
48 */
49 labels?: Record<string, string>;
50 /**
51 * Brief description.
52 */
53 description?: string;
54 /**
55 * Jupyter kernel config. Defaults to a PYTHON kernel when neither
56 * `jupyterSession` nor `sparkConnectSession` is set.
57 */
58 jupyterSession?: dataproc.JupyterConfig;
59 /**
60 * Spark Connect session config. Mutually exclusive with Jupyter.
61 */
62 sparkConnectSession?: dataproc.SparkConnectConfig;
63 /**
64 * Runtime configuration (version, properties, container image).
65 */
66 runtimeConfig?: dataproc.RuntimeConfig;
67 /**
68 * Environment configuration (execution and peripherals).
69 */
70 environmentConfig?: dataproc.EnvironmentConfig;
71};
72
73export type SessionTemplate = Resource<
74 "GCP.Dataproc.SessionTemplate",
75 SessionTemplateProps,
76 {
77 /** Full resource name `projects/{project}/locations/{location}/sessionTemplates/{template}`. */
78 name: string;
79 /** Template id (last path segment). */
80 templateId: string;
81 /** Project id. */
82 project: string;
83 /** Location id. */
84 location: string;
85 /** User labels (Alchemy ownership labels stripped). */
86 labels: Record<string, string>;
87 /** Description. */
88 description: string | undefined;
89 /** Jupyter kernel, if configured. */
90 jupyterKernel: string | undefined;
91 /** Server-assigned uuid. */
92 uuid: string | undefined;
93 /** RFC3339 creation timestamp. */
94 createTime: string | undefined;
95 /** RFC3339 last-update timestamp. */
96 updateTime: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A Dataproc serverless interactive session template.
104 *
105 * Changing `templateId` or `location` replaces the template. Description,
106 * labels, Jupyter config, runtime, and environment patch in place.
107 *
108 * ### Creating a Template
109 * **Example:** Generated name
110 * ```typescript
111 * const template = yield* GCP.Dataproc.SessionTemplate("Notebook", {});
112 * ```
113 *
114 * **Example:** Explicit Jupyter kernel
115 * ```typescript
116 * const template = yield* GCP.Dataproc.SessionTemplate("Notebook", {
117 * templateId: "analytics-nb",
118 * location: "us-central1",
119 * description: "python notebooks",
120 * jupyterSession: { kernel: "PYTHON" },
121 * labels: { env: "prod" },
122 * });
123 * ```
124 *
125 * @resource
126 * @category Dataproc
127 */
128export const SessionTemplate = Resource<SessionTemplate>(
129 "GCP.Dataproc.SessionTemplate",
130);
131
132export class SessionTemplateNotResolved extends Data.TaggedError(
133 "GCP.Dataproc.SessionTemplateNotResolved",
134)<{
135 name: string;
136}> {}
137
138const resourceName = (project: string, location: string, templateId: string) =>
139 `${locationParent(project, location)}/sessionTemplates/${templateId}`;
140
141// Dataproc rejects a Jupyter session template without a display name.
142const defaultJupyter = (
143 news: SessionTemplateProps,
144 templateId: string,
145): dataproc.JupyterConfig | undefined => {
146 if (news.sparkConnectSession !== undefined) return undefined;
147 const jupyter = news.jupyterSession ?? { kernel: "PYTHON" };
148 return { ...jupyter, displayName: jupyter.displayName ?? templateId };
149};
150
151const toAttrs = (
152 template: dataproc.SessionTemplate,
153 project: string,
154 location: string,
155) => {
156 const name = template.name ?? "";
157 const parsed = parseResourceName(name, "sessionTemplates", location);
158 return {
159 name,
160 templateId: parsed.id,
161 project: parsed.project || project,
162 location: parsed.location || location,
163 labels: userLabels(template.labels),
164 description: template.description,
165 jupyterKernel: template.jupyterSession?.kernel,
166 uuid: template.uuid,
167 createTime: template.createTime,
168 updateTime: template.updateTime,
169 };
170};
171
172const desiredBody = (
173 news: SessionTemplateProps,
174 name: string,
175 templateId: string,
176 desiredLabels: Record<string, string>,
177): dataproc.SessionTemplate => ({
178 name,
179 description: news.description,
180 labels: desiredLabels,
181 jupyterSession: defaultJupyter(news, templateId),
182 sparkConnectSession: news.sparkConnectSession,
183 runtimeConfig: news.runtimeConfig,
184 environmentConfig: news.environmentConfig,
185});
186
187const getByName = (name: string) =>
188 dataproc
189 .getProjectsLocationsSessionTemplates({ name })
190 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
191
192const listLocation = (project: string, location: string) =>
193 emptyOnMissing(
194 collectPages(
195 dataproc.listProjectsLocationsSessionTemplates.pages({
196 parent: locationParent(project, location),
197 pageSize: 1000,
198 }),
199 (page) => page.sessionTemplates,
200 ).pipe(
201 Effect.map((items) =>
202 items
203 .filter((template) => hasAlchemyLabelMap(template.labels))
204 .map((template) => toAttrs(template, project, location)),
205 ),
206 ),
207 );
208
209const templateChanged = (
210 current: dataproc.SessionTemplate,
211 desired: dataproc.SessionTemplate,
212) =>
213 (current.description ?? "") !== (desired.description ?? "") ||
214 fingerprint(current.jupyterSession) !== fingerprint(desired.jupyterSession) ||
215 fingerprint(current.sparkConnectSession) !==
216 fingerprint(desired.sparkConnectSession) ||
217 fingerprint(current.runtimeConfig) !== fingerprint(desired.runtimeConfig) ||
218 fingerprint(current.environmentConfig) !==
219 fingerprint(desired.environmentConfig);
220
221export const SessionTemplateProvider = () =>
222 Provider.succeed(SessionTemplate, {
223 stables: [
224 "name",
225 "templateId",
226 "project",
227 "location",
228 "uuid",
229 "createTime",
230 ],
231
232 diff: Effect.fn(function* ({ news, olds, output }) {
233 if (!isResolved(news)) return undefined;
234 const env = yield* GcpEnvironment.current;
235 const previousId = olds?.templateId ?? output?.templateId;
236 const nextId = news.templateId ?? previousId;
237 const previousLocation = normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 );
241 const nextLocation = normalizeLocation(
242 news.location ?? olds?.location ?? output?.location,
243 env.region,
244 );
245 if (
246 (previousId !== undefined &&
247 nextId !== undefined &&
248 previousId !== nextId) ||
249 (output !== undefined && previousLocation !== nextLocation)
250 ) {
251 return {
252 action: "replace" as const,
253 deleteFirst:
254 previousLocation === nextLocation &&
255 previousId !== undefined &&
256 nextId === previousId,
257 };
258 }
259 return undefined;
260 }),
261
262 read: Effect.fn(function* ({ id, olds, output }) {
263 const env = yield* GcpEnvironment.current;
264 const location = normalizeLocation(
265 olds?.location ?? output?.location,
266 env.region,
267 );
268 const templateId = yield* toPhysicalId(
269 id,
270 olds?.templateId,
271 output?.templateId,
272 MAX_SESSION_TEMPLATE_ID_LENGTH,
273 "session",
274 );
275 const name =
276 output?.name ?? resourceName(env.project, location, templateId);
277 const existing = yield* getByName(name);
278 if (existing === undefined) return undefined;
279 const attrs = toAttrs(existing, env.project, location);
280 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
281 ? attrs
282 : Unowned(attrs);
283 }),
284
285 list: () =>
286 Effect.gen(function* () {
287 const env = yield* GcpEnvironment.current;
288 const pages = yield* Effect.forEach(
289 [...new Set([env.region, ...LIST_LOCATIONS])],
290 (location) => listLocation(env.project, location),
291 { concurrency: 4 },
292 );
293 return pages.flat();
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output }) {
297 const env = yield* GcpEnvironment.current;
298 const location = normalizeLocation(
299 news.location ?? output?.location,
300 env.region,
301 );
302 const templateId = yield* toPhysicalId(
303 id,
304 news.templateId,
305 output?.templateId,
306 MAX_SESSION_TEMPLATE_ID_LENGTH,
307 "session",
308 );
309 const name = resourceName(env.project, location, templateId);
310 const desiredLabels = {
311 ...toLabels(news.labels),
312 ...(yield* createInternalLabels(id)),
313 };
314 const desired = desiredBody(news, name, templateId, desiredLabels);
315
316 let current = yield* getByName(name);
317
318 if (current === undefined) {
319 const created = yield* dataproc
320 .createProjectsLocationsSessionTemplates({
321 parent: locationParent(env.project, location),
322 body: desired,
323 })
324 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
325 current = created ?? undefined;
326 }
327
328 if (current === undefined) {
329 return yield* new SessionTemplateNotResolved({ name });
330 }
331
332 const observedLabels = tagRecord(current.labels);
333 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
334 const labelsChanged = upsert.length > 0 || removed.length > 0;
335
336 if (labelsChanged || templateChanged(current, desired)) {
337 current = yield* dataproc.patchProjectsLocationsSessionTemplates({
338 name: current.name ?? name,
339 body: {
340 ...desired,
341 name: current.name ?? name,
342 },
343 });
344 }
345
346 return toAttrs(current, env.project, location);
347 }),
348
349 delete: Effect.fn(function* ({ output }) {
350 if (!output.name) return;
351 yield* dataproc
352 .deleteProjectsLocationsSessionTemplates({ name: output.name })
353 .pipe(Effect.catchTag("NotFound", () => Effect.void));
354 yield* waitUntilGone(getByName(output.name), output.name);
355 }),
356 });