1import * as dataform from "@distilled.cloud/gcp/dataform_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, hasAlchemyLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 expandRepository,
13 forEachOwnedRepository,
14 hasAlchemyLabelMap,
15 listReleaseConfigs,
16 mergeOwnedVars,
17 normalizeLocation,
18 parseResourceName,
19 replaceOnIdentity,
20 retryTransient,
21 sameJson,
22 sameText,
23 toPhysicalId,
24 updateMaskOf,
25 userLabels,
26 waitUntilExists,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type NotebookRuntimeOptions = {
31 /** Colab runtime template resource name. */
32 aiPlatformNotebookRuntimeTemplate?: string;
33 /** GCS bucket for notebook output (`gs://bucket`). */
34 gcsOutputBucket?: string;
35};
36
37export type CodeCompilationConfig = {
38 /** Prefix prepended to table names. */
39 tablePrefix?: string;
40 /** Default notebook runtime options. */
41 defaultNotebookRuntimeOptions?: NotebookRuntimeOptions;
42 /** Default BigQuery location. Defaults to `US`. */
43 defaultLocation?: string;
44 /** Prefix prepended to built-in assertion names. */
45 builtinAssertionNamePrefix?: string;
46 /**
47 * User-defined compilation variables. Alchemy ownership keys
48 * (`alchemy-stack`, `alchemy-stage`, `alchemy-id`) are merged in
49 * automatically because release configs have no labels field.
50 */
51 vars?: Record<string, string>;
52 /** Default schema for assertions. */
53 assertionSchema?: string;
54 /** Suffix appended to database (project id) names. */
55 databaseSuffix?: string;
56 /** Suffix appended to schema (dataset) names. */
57 schemaSuffix?: string;
58 /** Default schema (BigQuery dataset id). */
59 defaultSchema?: string;
60 /** Default database (Google Cloud project id). */
61 defaultDatabase?: string;
62};
63
64export type RepositoriesReleaseConfigProps = {
65 /**
66 * Parent repository. Full name
67 * `projects/{project}/locations/{location}/repositories/{repository}`
68 * or the repository id (combined with `location`). Immutable —
69 * changing it replaces the release config.
70 */
71 repository: string;
72 /**
73 * Region used when `repository` is a bare id.
74 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
75 */
76 location?: string;
77 /**
78 * Release config id. If omitted, a unique RFC1035 name is generated.
79 * Immutable — changing it replaces the release config.
80 */
81 releaseConfigId?: string;
82 /**
83 * Git commit, tag, or branch compiled by this release
84 * (`main`, `12ade345`, `tag1`).
85 */
86 gitCommitish: string;
87 /**
88 * IANA time zone used when interpreting `cronSchedule`.
89 * @default "UTC"
90 */
91 timeZone?: string;
92 /**
93 * Cron schedule for automatic compilation-result creation.
94 */
95 cronSchedule?: string;
96 /**
97 * Disable automatic compilation-result creation.
98 * @default false
99 */
100 disabled?: boolean;
101 /**
102 * Compilation overrides. Alchemy ownership vars are merged in.
103 */
104 codeCompilationConfig?: CodeCompilationConfig;
105 /**
106 * Currently released compilation result
107 * (`.../repositories/{repository}/compilationResults/{result}`).
108 */
109 releaseCompilationResult?: string;
110};
111
112export type RepositoriesReleaseConfig = Resource<
113 "GCP.Dataform.RepositoriesReleaseConfig",
114 RepositoriesReleaseConfigProps,
115 {
116 /** Full resource name `.../repositories/{repository}/releaseConfigs/{releaseConfig}`. */
117 name: string;
118 /** Release config id (last path segment). */
119 releaseConfigId: string;
120 /** Parent repository resource name. */
121 repository: string;
122 /** Project id. */
123 project: string;
124 /** Location id. */
125 location: string;
126 /** Git commitish. */
127 gitCommitish: string;
128 /** Cron schedule, if set. */
129 cronSchedule: string | undefined;
130 /** Time zone. */
131 timeZone: string | undefined;
132 /** Whether automatic releases are disabled. */
133 disabled: boolean;
134 /** User compilation vars (Alchemy ownership keys stripped). */
135 vars: Record<string, string>;
136 /** Currently released compilation result, if any. */
137 releaseCompilationResult: string | undefined;
138 },
139 never,
140 Providers
141>;
142
143/**
144 * A Dataform release configuration — compiles a Git commitish on a
145 * schedule (or on demand) into a compilation result.
146 *
147 * Release configs have no labels field, so Alchemy stamps ownership
148 * into `codeCompilationConfig.vars`. Changing `releaseConfigId`,
149 * `repository`, or `location` replaces the config. Commitish, schedule,
150 * disabled flag, vars, and the current compilation result update in
151 * place.
152 *
153 * ### Creating a Release Config
154 * **Example:** Compile `main`
155 * ```typescript
156 * const release = yield* GCP.Dataform.RepositoriesReleaseConfig("Prod", {
157 * repository: repo.name,
158 * gitCommitish: "main",
159 * });
160 * ```
161 *
162 * **Example:** Nightly release
163 * ```typescript
164 * const release = yield* GCP.Dataform.RepositoriesReleaseConfig("Nightly", {
165 * repository: repo.name,
166 * gitCommitish: "main",
167 * cronSchedule: "0 8 * * *",
168 * timeZone: "America/Los_Angeles",
169 * });
170 * ```
171 *
172 * @resource
173 * @category Dataform
174 */
175export const RepositoriesReleaseConfig = Resource<RepositoriesReleaseConfig>(
176 "GCP.Dataform.RepositoriesReleaseConfig",
177);
178
179export class RepositoriesReleaseConfigNotResolved extends Data.TaggedError(
180 "GCP.Dataform.RepositoriesReleaseConfigNotResolved",
181)<{
182 name: string;
183}> {}
184
185const resourceName = (repository: string, releaseConfigId: string) =>
186 `${repository}/releaseConfigs/${releaseConfigId}`;
187
188const compilationOf = (
189 config: CodeCompilationConfig | undefined,
190 ownership: Record<string, string>,
191): dataform.CodeCompilationConfig => ({
192 tablePrefix: config?.tablePrefix,
193 defaultNotebookRuntimeOptions: config?.defaultNotebookRuntimeOptions,
194 defaultLocation: config?.defaultLocation,
195 builtinAssertionNamePrefix: config?.builtinAssertionNamePrefix,
196 vars: mergeOwnedVars(config?.vars, ownership),
197 assertionSchema: config?.assertionSchema,
198 databaseSuffix: config?.databaseSuffix,
199 schemaSuffix: config?.schemaSuffix,
200 defaultSchema: config?.defaultSchema,
201 defaultDatabase: config?.defaultDatabase,
202});
203
204const toAttrs = (
205 config: dataform.ReleaseConfig,
206 project: string,
207 region: string,
208) => {
209 const name = config.name ?? "";
210 const parsed = parseResourceName(name, "releaseConfigs", region);
211 return {
212 name,
213 releaseConfigId: parsed.id,
214 repository: parsed.parent,
215 project: parsed.project || project,
216 location: parsed.location,
217 gitCommitish: config.gitCommitish ?? "",
218 cronSchedule: config.cronSchedule,
219 timeZone: config.timeZone,
220 disabled: config.disabled === true,
221 vars: userLabels(config.codeCompilationConfig?.vars),
222 releaseCompilationResult: config.releaseCompilationResult,
223 };
224};
225
226const getByName = (name: string) =>
227 name.length === 0 || name.includes("//")
228 ? Effect.succeed(undefined)
229 : dataform
230 .getProjectsLocationsRepositoriesReleaseConfigs({ name })
231 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
232
233const isOwnedConfig = (config: dataform.ReleaseConfig) =>
234 hasAlchemyLabelMap(config.codeCompilationConfig?.vars);
235
236export const RepositoriesReleaseConfigProvider = () =>
237 Provider.succeed(RepositoriesReleaseConfig, {
238 stables: ["name", "releaseConfigId", "repository", "project", "location"],
239
240 diff: Effect.fn(function* ({ news, olds, output }) {
241 if (!isResolved(news)) return undefined;
242 const env = yield* GcpEnvironment.current;
243 const location = normalizeLocation(
244 news.location ?? olds?.location ?? output?.location,
245 env.region,
246 );
247 return replaceOnIdentity({
248 previousId: olds?.releaseConfigId ?? output?.releaseConfigId,
249 nextId:
250 news.releaseConfigId ??
251 olds?.releaseConfigId ??
252 output?.releaseConfigId,
253 previousLocation: olds?.location ?? output?.location,
254 nextLocation: news.location ?? olds?.location ?? output?.location,
255 previousParent: olds?.repository ?? output?.repository,
256 nextParent: expandRepository(news.repository, env.project, location),
257 });
258 }),
259
260 read: Effect.fn(function* ({ id, olds, output }) {
261 const env = yield* GcpEnvironment.current;
262 const location = normalizeLocation(
263 olds?.location ?? output?.location,
264 env.region,
265 );
266 const repository = expandRepository(
267 olds?.repository ??
268 output?.repository ??
269 parseResourceName(output?.name ?? "", "releaseConfigs", env.region)
270 .parent,
271 env.project,
272 location,
273 );
274 const releaseConfigId = yield* toPhysicalId(
275 id,
276 olds?.releaseConfigId,
277 output?.releaseConfigId,
278 );
279 const name = output?.name ?? resourceName(repository, releaseConfigId);
280 const existing = yield* getByName(name);
281 if (existing === undefined) return undefined;
282 const attrs = toAttrs(existing, env.project, env.region);
283 const labeled = yield* hasAlchemyLabels(
284 id,
285 existing.codeCompilationConfig?.vars,
286 );
287 return labeled || isOwnedConfig(existing) ? attrs : Unowned(attrs);
288 }),
289
290 list: () =>
291 Effect.gen(function* () {
292 const env = yield* GcpEnvironment.current;
293 const configs = yield* forEachOwnedRepository(
294 env.project,
295 env.region,
296 (repo) => listReleaseConfigs(repo.name ?? ""),
297 );
298 return configs.map((item) => toAttrs(item, env.project, env.region));
299 }),
300
301 reconcile: Effect.fn(function* ({ id, news, output }) {
302 const env = yield* GcpEnvironment.current;
303 const location = normalizeLocation(
304 news.location ?? output?.location,
305 env.region,
306 );
307 const repository = expandRepository(
308 news.repository,
309 env.project,
310 location,
311 );
312 const releaseConfigId = yield* toPhysicalId(
313 id,
314 news.releaseConfigId,
315 output?.releaseConfigId,
316 );
317 const name = resourceName(repository, releaseConfigId);
318 const ownership = yield* createInternalLabels(id);
319 const codeCompilationConfig = compilationOf(
320 news.codeCompilationConfig,
321 ownership,
322 );
323 const disabled = news.disabled === true;
324
325 let current = yield* getByName(output?.name ?? name);
326
327 if (current === undefined) {
328 const created = yield* retryTransient(
329 dataform.createProjectsLocationsRepositoriesReleaseConfigs({
330 parent: repository,
331 releaseConfigId,
332 body: {
333 gitCommitish: news.gitCommitish,
334 timeZone: news.timeZone,
335 cronSchedule: news.cronSchedule,
336 disabled,
337 codeCompilationConfig,
338 releaseCompilationResult: news.releaseCompilationResult,
339 },
340 }),
341 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
342 current = created ?? undefined;
343 if (current === undefined) {
344 current = yield* waitUntilExists(getByName(name), name);
345 }
346 }
347
348 if (current === undefined) {
349 return yield* new RepositoriesReleaseConfigNotResolved({ name });
350 }
351
352 const currentName = current.name ?? name;
353 const gitChanged = !sameText(current.gitCommitish, news.gitCommitish);
354 const zoneChanged = !sameText(current.timeZone, news.timeZone);
355 const cronChanged = !sameText(current.cronSchedule, news.cronSchedule);
356 const disabledChanged = (current.disabled === true) !== disabled;
357 const compilationChanged = !sameJson(
358 {
359 ...current.codeCompilationConfig,
360 vars: mergeOwnedVars(
361 userLabels(current.codeCompilationConfig?.vars),
362 ownership,
363 ),
364 },
365 codeCompilationConfig,
366 );
367 const resultChanged = !sameText(
368 current.releaseCompilationResult,
369 news.releaseCompilationResult,
370 );
371
372 if (
373 gitChanged ||
374 zoneChanged ||
375 cronChanged ||
376 disabledChanged ||
377 compilationChanged ||
378 resultChanged
379 ) {
380 current = yield* retryTransient(
381 dataform.patchProjectsLocationsRepositoriesReleaseConfigs({
382 name: currentName,
383 updateMask: updateMaskOf(
384 gitChanged ? "gitCommitish" : undefined,
385 zoneChanged ? "timeZone" : undefined,
386 cronChanged ? "cronSchedule" : undefined,
387 disabledChanged ? "disabled" : undefined,
388 compilationChanged ? "codeCompilationConfig" : undefined,
389 resultChanged ? "releaseCompilationResult" : undefined,
390 ),
391 body: {
392 gitCommitish: news.gitCommitish,
393 timeZone: news.timeZone,
394 cronSchedule: news.cronSchedule,
395 disabled,
396 codeCompilationConfig,
397 releaseCompilationResult: news.releaseCompilationResult,
398 },
399 }),
400 );
401 }
402
403 return toAttrs(current, env.project, env.region);
404 }),
405
406 delete: Effect.fn(function* ({ output }) {
407 if (!output.name) return;
408 yield* retryTransient(
409 dataform.deleteProjectsLocationsRepositoriesReleaseConfigs({
410 name: output.name,
411 }),
412 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
413 yield* waitUntilGone(getByName(output.name));
414 }),
415 });