| 1 | import * as integrations from "@distilled.cloud/gcp/integrations_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { GcpEnvironment } from "../Environment.ts"; |
| 9 | import { createInternalLabels } from "../Labels.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import { findTemplateByDescription, listTemplates } from "./internal.ts"; |
| 12 | import { |
| 13 | encodeOwnership, |
| 14 | fingerprint, |
| 15 | hasOwnershipMarker, |
| 16 | lastSegment, |
| 17 | locationOf, |
| 18 | locationParent, |
| 19 | MAX_TEMPLATE_DESCRIPTION_LENGTH, |
| 20 | normalizeLocation, |
| 21 | ownedByAlchemy, |
| 22 | parseOwnership, |
| 23 | withProjectId, |
| 24 | replaceOnIdentity, |
| 25 | sameText, |
| 26 | toResourceId, |
| 27 | updateMaskOf, |
| 28 | } from "./ownership.ts"; |
| 29 | |
| 30 | export type TemplateCategory = |
| 31 | | "CATEGORY_UNSPECIFIED" |
| 32 | | "AI_MACHINE_LEARNING" |
| 33 | | "BUSINESS_INTELLIGENCE" |
| 34 | | "COLLABORATION" |
| 35 | | "CUSTOMER_SERVICE" |
| 36 | | "DATABASES" |
| 37 | | "DEVOPS_IT" |
| 38 | | "CONTENT_AND_FILES" |
| 39 | | "FINANCE_AND_ACCOUNTING" |
| 40 | | "HUMAN_RESOURCES" |
| 41 | | "OPERATIONS" |
| 42 | | "PRODUCT_PROJECT_MANAGEMENT" |
| 43 | | "PRODUCTIVITY" |
| 44 | | "SALES_AND_MARKETING" |
| 45 | | "UNIVERSAL_CONNECTORS" |
| 46 | | "UTILITY" |
| 47 | | "OTHERS"; |
| 48 | |
| 49 | export type TemplateVisibility = |
| 50 | | "VISIBILITY_UNSPECIFIED" |
| 51 | | "PRIVATE" |
| 52 | | "SHARED" |
| 53 | | "PUBLIC"; |
| 54 | |
| 55 | export type TemplateComponentType = |
| 56 | | "TYPE_UNSPECIFIED" |
| 57 | | "TRIGGER" |
| 58 | | "TASK" |
| 59 | | "CONNECTOR"; |
| 60 | |
| 61 | export type TemplateComponent = { |
| 62 | /** Component type. */ |
| 63 | type?: TemplateComponentType | (string & {}); |
| 64 | /** Component name. */ |
| 65 | name?: string; |
| 66 | }; |
| 67 | |
| 68 | export type TemplateBundle = |
| 69 | integrations.GoogleCloudIntegrationsV1alphaTemplateBundle; |
| 70 | |
| 71 | export type TemplateProps = { |
| 72 | /** |
| 73 | * Template id (the `{template}` segment of |
| 74 | * `projects/{project}/locations/{location}/templates/{template}`). |
| 75 | * Server-assigned on create when omitted. Immutable — changing it |
| 76 | * replaces the template. |
| 77 | */ |
| 78 | templateId?: string; |
| 79 | /** |
| 80 | * Location (`us-central1`, …). Immutable — changing it replaces the |
| 81 | * template. `US-CENTRAL1` is accepted and normalized to `us-central1`. |
| 82 | * @default the stack's GCP region (`GCP.Region`, else the profile |
| 83 | * region, else `us-central1`) |
| 84 | */ |
| 85 | location?: string; |
| 86 | /** |
| 87 | * Template display name. Generated from the logical id when omitted. |
| 88 | */ |
| 89 | displayName?: string; |
| 90 | /** |
| 91 | * Description (max 255 characters). Templates have no labels field, so |
| 92 | * Alchemy stamps ownership into a `[alchemy …]` prefix and strips it |
| 93 | * from attributes. |
| 94 | */ |
| 95 | description?: string; |
| 96 | /** |
| 97 | * How to use the template. |
| 98 | */ |
| 99 | usageInfo?: string; |
| 100 | /** |
| 101 | * Link to template documentation. |
| 102 | */ |
| 103 | docLink?: string; |
| 104 | /** |
| 105 | * Bundle converted into an integration when the template is used. When |
| 106 | * omitted, a minimal API-trigger integration is used. |
| 107 | */ |
| 108 | templateBundle?: TemplateBundle; |
| 109 | /** |
| 110 | * Components used for categorization and filtering. |
| 111 | */ |
| 112 | components?: TemplateComponent[]; |
| 113 | /** |
| 114 | * Business tags used to identify the template. |
| 115 | * @default ["utility"] |
| 116 | */ |
| 117 | tags?: string[]; |
| 118 | /** |
| 119 | * Categories used when listing templates. |
| 120 | * @default ["UTILITY"] |
| 121 | */ |
| 122 | categories?: Array<TemplateCategory | (string & {})>; |
| 123 | /** |
| 124 | * Template author. |
| 125 | */ |
| 126 | author?: string; |
| 127 | /** |
| 128 | * Visibility of the template. |
| 129 | * @default "PRIVATE" |
| 130 | */ |
| 131 | visibility?: TemplateVisibility | (string & {}); |
| 132 | /** |
| 133 | * Resource names the template is shared with (project numbers or org |
| 134 | * ids). |
| 135 | */ |
| 136 | sharedWith?: string[]; |
| 137 | }; |
| 138 | |
| 139 | export type Template = Resource< |
| 140 | "GCP.Integrations.Template", |
| 141 | TemplateProps, |
| 142 | { |
| 143 | /** Full resource name `projects/{project}/locations/{location}/templates/{id}`. */ |
| 144 | name: string; |
| 145 | /** Template id (last path segment). */ |
| 146 | templateId: string; |
| 147 | /** Location id. */ |
| 148 | location: string; |
| 149 | /** Project id. */ |
| 150 | project: string; |
| 151 | /** Display name. */ |
| 152 | displayName: string | undefined; |
| 153 | /** User description with the Alchemy ownership prefix stripped. */ |
| 154 | description: string | undefined; |
| 155 | /** Usage notes. */ |
| 156 | usageInfo: string | undefined; |
| 157 | /** Documentation link. */ |
| 158 | docLink: string | undefined; |
| 159 | /** Integration bundle. */ |
| 160 | templateBundle: TemplateBundle | undefined; |
| 161 | /** Components. */ |
| 162 | components: TemplateComponent[]; |
| 163 | /** Business tags. */ |
| 164 | tags: string[]; |
| 165 | /** Categories. */ |
| 166 | categories: string[]; |
| 167 | /** Author. */ |
| 168 | author: string | undefined; |
| 169 | /** Visibility. */ |
| 170 | visibility: string | undefined; |
| 171 | /** Share targets. */ |
| 172 | sharedWith: string[]; |
| 173 | /** Usage count. */ |
| 174 | usageCount: string | undefined; |
| 175 | /** Last used time. */ |
| 176 | lastUsedTime: string | undefined; |
| 177 | /** RFC3339 creation timestamp. */ |
| 178 | createTime: string | undefined; |
| 179 | /** RFC3339 last-update timestamp. */ |
| 180 | updateTime: string | undefined; |
| 181 | }, |
| 182 | never, |
| 183 | Providers |
| 184 | >; |
| 185 | |
| 186 | const DEFAULT_TAGS = ["utility"]; |
| 187 | const DEFAULT_CATEGORIES: TemplateCategory[] = ["UTILITY"]; |
| 188 | const DEFAULT_VISIBILITY: TemplateVisibility = "PRIVATE"; |
| 189 | |
| 190 | export const defaultTemplateBundle = (): TemplateBundle => ({ |
| 191 | integrationVersionTemplate: { |
| 192 | key: "main", |
| 193 | integrationVersion: { |
| 194 | triggerConfigs: [ |
| 195 | { |
| 196 | label: "API Trigger", |
| 197 | triggerNumber: "1", |
| 198 | triggerType: "API", |
| 199 | triggerId: "api_trigger/alchemy", |
| 200 | properties: { "Trigger name": "alchemy" }, |
| 201 | }, |
| 202 | ], |
| 203 | }, |
| 204 | }, |
| 205 | }); |
| 206 | |
| 207 | /** |
| 208 | * An Application Integration template: a reusable integration bundle plus |
| 209 | * metadata (categories, tags, visibility). |
| 210 | * |
| 211 | * Templates have no labels field, so Alchemy stamps ownership into the |
| 212 | * description for `list` / nuke. Location and id are identity — changing |
| 213 | * them replaces the template. Display name, description, bundle, tags, |
| 214 | * categories, and visibility update in place. |
| 215 | * |
| 216 | * ### Creating a Template |
| 217 | * **Example:** Private utility template |
| 218 | * ```typescript |
| 219 | * const template = yield* GCP.Integrations.Template("Orders", { |
| 220 | * displayName: "order-sync", |
| 221 | * description: "sync orders", |
| 222 | * tags: ["orders"], |
| 223 | * categories: ["SALES_AND_MARKETING"], |
| 224 | * }); |
| 225 | * ``` |
| 226 | * |
| 227 | * ### Updating a Template |
| 228 | * Re-declare the same logical id with changed props; the engine keeps the |
| 229 | * physical resource and updates it in place. |
| 230 | * |
| 231 | * **Example:** Rename and retag |
| 232 | * ```typescript |
| 233 | * const template = yield* GCP.Integrations.Template("Orders", { |
| 234 | * displayName: "order-sync-v2", |
| 235 | * description: "sync orders v2", |
| 236 | * tags: ["orders", "v2"], |
| 237 | * categories: ["SALES_AND_MARKETING"], |
| 238 | * }); |
| 239 | * ``` |
| 240 | * |
| 241 | * @resource |
| 242 | * @category Integrations |
| 243 | */ |
| 244 | export const Template = Resource<Template>("GCP.Integrations.Template"); |
| 245 | |
| 246 | export class TemplateNotResolved extends Data.TaggedError( |
| 247 | "GCP.Integrations.TemplateNotResolved", |
| 248 | )<{ |
| 249 | name: string; |
| 250 | }> {} |
| 251 | |
| 252 | const resourceName = (project: string, location: string, templateId: string) => |
| 253 | `${locationParent(project, location)}/templates/${templateId}`; |
| 254 | |
| 255 | const toAttrs = ( |
| 256 | template: integrations.GoogleCloudIntegrationsV1alphaTemplate, |
| 257 | project: string, |
| 258 | region: string, |
| 259 | ) => { |
| 260 | const name = withProjectId(template.name ?? "", project); |
| 261 | return { |
| 262 | name, |
| 263 | templateId: lastSegment(name), |
| 264 | location: locationOf(name, region), |
| 265 | project, |
| 266 | displayName: template.displayName, |
| 267 | description: parseOwnership(template.description).text, |
| 268 | usageInfo: template.usageInfo, |
| 269 | docLink: template.docLink, |
| 270 | templateBundle: template.templateBundle, |
| 271 | components: [...(template.components ?? [])], |
| 272 | tags: [...(template.tags ?? [])], |
| 273 | categories: [...(template.categories ?? [])], |
| 274 | author: template.author, |
| 275 | visibility: template.visibility, |
| 276 | sharedWith: [...(template.sharedWith ?? [])], |
| 277 | usageCount: template.usageCount, |
| 278 | lastUsedTime: template.lastUsedTime, |
| 279 | createTime: template.createTime, |
| 280 | updateTime: template.updateTime, |
| 281 | }; |
| 282 | }; |
| 283 | |
| 284 | const getByName = (name: string) => |
| 285 | name.length === 0 |
| 286 | ? Effect.succeed(undefined) |
| 287 | : integrations |
| 288 | .getProjectsLocationsTemplates({ name }) |
| 289 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 290 | |
| 291 | export const TemplateProvider = () => |
| 292 | Provider.succeed(Template, { |
| 293 | stables: ["name", "templateId", "location", "project", "createTime"], |
| 294 | |
| 295 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 296 | if (!isResolved(news)) return undefined; |
| 297 | const env = yield* GcpEnvironment.current; |
| 298 | const previousId = olds?.templateId ?? output?.templateId; |
| 299 | const idChanged = |
| 300 | previousId !== undefined && |
| 301 | news.templateId !== undefined && |
| 302 | news.templateId !== previousId; |
| 303 | const previousLocation = olds?.location ?? output?.location; |
| 304 | const locationChanged = |
| 305 | previousLocation !== undefined && |
| 306 | news.location !== undefined && |
| 307 | normalizeLocation(news.location, env.region) !== |
| 308 | normalizeLocation(previousLocation, env.region); |
| 309 | return replaceOnIdentity(idChanged || locationChanged); |
| 310 | }), |
| 311 | |
| 312 | read: Effect.fn(function* ({ id, olds, output }) { |
| 313 | const env = yield* GcpEnvironment.current; |
| 314 | const templateId = yield* toResourceId( |
| 315 | id, |
| 316 | olds?.templateId, |
| 317 | output?.templateId, |
| 318 | ); |
| 319 | const location = normalizeLocation( |
| 320 | olds?.location ?? output?.location, |
| 321 | env.region, |
| 322 | ); |
| 323 | const name = |
| 324 | output?.name ?? resourceName(env.project, location, templateId); |
| 325 | let existing = yield* getByName(name); |
| 326 | if (existing === undefined && output?.name === undefined) { |
| 327 | const ownership = yield* createInternalLabels(id); |
| 328 | existing = yield* findTemplateByDescription( |
| 329 | locationParent(env.project, location), |
| 330 | encodeOwnership( |
| 331 | ownership, |
| 332 | olds?.description, |
| 333 | MAX_TEMPLATE_DESCRIPTION_LENGTH, |
| 334 | ), |
| 335 | ); |
| 336 | } |
| 337 | if (existing === undefined) return undefined; |
| 338 | const attrs = toAttrs(existing, env.project, env.region); |
| 339 | return (yield* ownedByAlchemy(id, existing.description)) |
| 340 | ? attrs |
| 341 | : Unowned(attrs); |
| 342 | }), |
| 343 | |
| 344 | list: () => |
| 345 | Effect.gen(function* () { |
| 346 | const env = yield* GcpEnvironment.current; |
| 347 | const items = yield* listTemplates( |
| 348 | locationParent(env.project, env.region), |
| 349 | ); |
| 350 | return items |
| 351 | .filter((template) => hasOwnershipMarker(template.description)) |
| 352 | .map((template) => toAttrs(template, env.project, env.region)); |
| 353 | }), |
| 354 | |
| 355 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 356 | const env = yield* GcpEnvironment.current; |
| 357 | const location = normalizeLocation( |
| 358 | news.location ?? output?.location, |
| 359 | env.region, |
| 360 | ); |
| 361 | const parent = locationParent(env.project, location); |
| 362 | const templateId = yield* toResourceId( |
| 363 | id, |
| 364 | news.templateId, |
| 365 | output?.templateId, |
| 366 | ); |
| 367 | const name = |
| 368 | output?.name ?? resourceName(env.project, location, templateId); |
| 369 | const ownership = yield* createInternalLabels(id); |
| 370 | const description = encodeOwnership( |
| 371 | ownership, |
| 372 | news.description, |
| 373 | MAX_TEMPLATE_DESCRIPTION_LENGTH, |
| 374 | ); |
| 375 | const displayName = news.displayName ?? templateId; |
| 376 | const templateBundle = news.templateBundle ?? defaultTemplateBundle(); |
| 377 | const tags = news.tags ?? DEFAULT_TAGS; |
| 378 | const categories = news.categories ?? DEFAULT_CATEGORIES; |
| 379 | const visibility = news.visibility ?? DEFAULT_VISIBILITY; |
| 380 | const components = news.components; |
| 381 | const sharedWith = news.sharedWith; |
| 382 | const body: integrations.GoogleCloudIntegrationsV1alphaTemplate = { |
| 383 | displayName, |
| 384 | description, |
| 385 | usageInfo: news.usageInfo, |
| 386 | docLink: news.docLink, |
| 387 | templateBundle, |
| 388 | components, |
| 389 | tags, |
| 390 | categories, |
| 391 | author: news.author, |
| 392 | visibility, |
| 393 | sharedWith, |
| 394 | }; |
| 395 | |
| 396 | let current = yield* getByName(output?.name ?? name); |
| 397 | if (current === undefined) { |
| 398 | current = yield* findTemplateByDescription(parent, description); |
| 399 | } |
| 400 | |
| 401 | if (current === undefined) { |
| 402 | const created = yield* integrations |
| 403 | .createProjectsLocationsTemplates({ |
| 404 | parent, |
| 405 | body, |
| 406 | }) |
| 407 | .pipe( |
| 408 | Effect.catchTag("Conflict", () => |
| 409 | findTemplateByDescription(parent, description), |
| 410 | ), |
| 411 | ); |
| 412 | current = created ?? undefined; |
| 413 | } |
| 414 | |
| 415 | if (current === undefined) { |
| 416 | return yield* new TemplateNotResolved({ name }); |
| 417 | } |
| 418 | |
| 419 | const currentName = withProjectId(current.name ?? name, env.project); |
| 420 | const displayChanged = !sameText(current.displayName, displayName); |
| 421 | const descriptionChanged = (current.description ?? "") !== description; |
| 422 | const usageChanged = !sameText(current.usageInfo, news.usageInfo); |
| 423 | const docChanged = !sameText(current.docLink, news.docLink); |
| 424 | const bundleChanged = |
| 425 | fingerprint(current.templateBundle) !== fingerprint(templateBundle); |
| 426 | const componentsChanged = |
| 427 | fingerprint(current.components) !== fingerprint(components); |
| 428 | const tagsChanged = fingerprint(current.tags) !== fingerprint(tags); |
| 429 | const categoriesChanged = |
| 430 | fingerprint(current.categories) !== fingerprint(categories); |
| 431 | const authorChanged = !sameText(current.author, news.author); |
| 432 | const visibilityChanged = !sameText(current.visibility, visibility); |
| 433 | const sharedChanged = |
| 434 | fingerprint(current.sharedWith) !== fingerprint(sharedWith); |
| 435 | |
| 436 | if ( |
| 437 | displayChanged || |
| 438 | descriptionChanged || |
| 439 | usageChanged || |
| 440 | docChanged || |
| 441 | bundleChanged || |
| 442 | componentsChanged || |
| 443 | tagsChanged || |
| 444 | categoriesChanged || |
| 445 | authorChanged || |
| 446 | visibilityChanged || |
| 447 | sharedChanged |
| 448 | ) { |
| 449 | current = yield* integrations.patchProjectsLocationsTemplates({ |
| 450 | name: currentName, |
| 451 | updateMask: updateMaskOf( |
| 452 | displayChanged ? "displayName" : undefined, |
| 453 | descriptionChanged ? "description" : undefined, |
| 454 | usageChanged ? "usageInfo" : undefined, |
| 455 | docChanged ? "docLink" : undefined, |
| 456 | bundleChanged ? "templateBundle" : undefined, |
| 457 | componentsChanged ? "components" : undefined, |
| 458 | tagsChanged ? "tags" : undefined, |
| 459 | categoriesChanged ? "categories" : undefined, |
| 460 | authorChanged ? "author" : undefined, |
| 461 | visibilityChanged ? "visibility" : undefined, |
| 462 | sharedChanged ? "sharedWith" : undefined, |
| 463 | ), |
| 464 | body: { ...body, name: currentName }, |
| 465 | }); |
| 466 | } |
| 467 | |
| 468 | return toAttrs(current, env.project, env.region); |
| 469 | }), |
| 470 | |
| 471 | delete: Effect.fn(function* ({ output }) { |
| 472 | yield* integrations |
| 473 | .deleteProjectsLocationsTemplates({ name: output.name }) |
| 474 | .pipe(Effect.catchTag("NotFound", () => Effect.void)); |
| 475 | }), |
| 476 | }); |