@bjorntech/alchemy-azure 0.2.0-beta.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ import type { ContainerApp as AzureContainerApp } from "@azure/arm-appcontainers";
2
+ import * as Config from "effect/Config";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Redacted from "effect/Redacted";
5
+ import { Unowned } from "alchemy/AdoptPolicy";
6
+ import { isResolved } from "alchemy/Diff";
7
+ import * as Output from "alchemy/Output";
8
+ import { Platform, type Main, type PlatformProps } from "alchemy/Platform";
9
+ import type { BaseRuntimeContext } from "alchemy/RuntimeContext";
10
+ import type { Resource } from "alchemy";
11
+ import * as Provider from "alchemy/Provider";
12
+ import { makeAzureClients } from "./Clients.ts";
13
+ import { azureError, isNotFound } from "./Errors.ts";
14
+ import {
15
+ collectAzurePages,
16
+ makePhysicalNames,
17
+ requireLocation,
18
+ resourceGroupName,
19
+ resolveResourceValue,
20
+ withHeartbeat,
21
+ } from "./Internal.ts";
22
+ import type { ContainerAppEnvironment } from "./ContainerAppEnvironment.ts";
23
+ import type { ContainerImage } from "./ContainerImage.ts";
24
+ import type { ContainerRegistry } from "./ContainerRegistry.ts";
25
+ import type { Providers } from "./Providers.ts";
26
+ import type { ResourceProviderRegistration } from "./ResourceProviderRegistration.ts";
27
+ import type { ResourceGroup } from "./ResourceGroup.ts";
28
+ import { hasAlchemyTags, withAlchemyTags } from "./ResourceGroup.ts";
29
+
30
+ export interface ContainerAppProps extends PlatformProps {
31
+ /**
32
+ * Container App name. Defaults to a deterministic Azure-safe physical name.
33
+ */
34
+ name?: string;
35
+ /**
36
+ * Resource group object or name containing the Container App.
37
+ */
38
+ resourceGroup: string | ResourceGroup;
39
+ /**
40
+ * Azure region. Defaults to the resource group's location when a ResourceGroup object is supplied.
41
+ */
42
+ location?: string;
43
+ /**
44
+ * Managed environment object or resource ID.
45
+ */
46
+ environment: string | ContainerAppEnvironment;
47
+ /**
48
+ * Container image to run, for example `ghcr.io/acme/api:latest`, or a ContainerImage resource.
49
+ */
50
+ image: string | ContainerImage;
51
+ /**
52
+ * Optional Azure Container Registry for private image pulls.
53
+ */
54
+ registry?: ContainerRegistry;
55
+ /** Registry username. Defaults to registry.username when registry is supplied. */
56
+ registryUsername?: string;
57
+ /** Registry password. Defaults to registry.password when registry is supplied. */
58
+ registryPassword?: string | Redacted.Redacted<string>;
59
+ /**
60
+ * Optional hash from an external build step. Changing this creates a new revision.
61
+ */
62
+ buildHash?: string;
63
+ /**
64
+ * Container port exposed by HTTP ingress.
65
+ * @default 3000
66
+ */
67
+ targetPort?: number;
68
+ /**
69
+ * Whether ingress is externally reachable.
70
+ * @default true
71
+ */
72
+ external?: boolean;
73
+ /**
74
+ * Container environment variables.
75
+ */
76
+ env?: Record<string, unknown>;
77
+ /**
78
+ * Container CPU cores.
79
+ * @default 0.5
80
+ */
81
+ cpu?: number;
82
+ /**
83
+ * Container memory, for example `1Gi`.
84
+ * @default "1Gi"
85
+ */
86
+ memory?: string;
87
+ /**
88
+ * Minimum replica count.
89
+ * @default 0
90
+ */
91
+ minReplicas?: number;
92
+ /**
93
+ * Maximum replica count.
94
+ * @default 1
95
+ */
96
+ maxReplicas?: number;
97
+ /**
98
+ * Container name inside the app template.
99
+ * @default "app"
100
+ */
101
+ containerName?: string;
102
+ /**
103
+ * Tags to apply to the Container App.
104
+ */
105
+ tags?: Record<string, string>;
106
+ /** Optional provider registration dependency, usually `Microsoft.App`. */
107
+ providerRegistration?: ResourceProviderRegistration;
108
+ /**
109
+ * Whether to delete the Container App when removed from Alchemy.
110
+ * @default true
111
+ */
112
+ delete?: boolean;
113
+ }
114
+
115
+ export interface ContainerAppRuntimeContext extends BaseRuntimeContext {
116
+ readonly Type: "Azure.ContainerApp";
117
+ }
118
+
119
+ export type ContainerApp = Resource<
120
+ "Azure.ContainerApp",
121
+ ContainerAppProps,
122
+ {
123
+ name: string;
124
+ resourceGroupName: string;
125
+ location: string;
126
+ containerAppId: string;
127
+ environmentId: string;
128
+ image: string;
129
+ buildHash?: string;
130
+ targetPort: number;
131
+ fqdn?: string;
132
+ url?: string;
133
+ latestRevisionName?: string;
134
+ latestReadyRevisionName?: string;
135
+ provisioningState?: string;
136
+ runningStatus?: string;
137
+ tags?: Record<string, string>;
138
+ },
139
+ never,
140
+ Providers
141
+ >;
142
+
143
+ export type ContainerAppServices = never;
144
+ export type ContainerAppShape = Main<ContainerAppServices>;
145
+ export type ContainerAppPlatform = Platform<
146
+ ContainerApp,
147
+ ContainerAppServices,
148
+ ContainerAppShape,
149
+ ContainerAppRuntimeContext
150
+ > & {
151
+ (
152
+ id: string,
153
+ props: ContainerAppProps,
154
+ ): Effect.Effect<ContainerApp, never, ContainerApp["Providers"]>;
155
+ };
156
+
157
+ /**
158
+ * Azure Container App — an experimental Alchemy v2 runtime host backed by Azure Container Apps.
159
+ *
160
+ * @example Explicit image
161
+ * ```ts
162
+ * const app = yield* Azure.ContainerApp("Api", {
163
+ * resourceGroup: group,
164
+ * environmentId: managedEnvironmentId,
165
+ * image: "ghcr.io/acme/api:latest",
166
+ * targetPort: 3000,
167
+ * });
168
+ * ```
169
+ */
170
+ export const ContainerApp: ContainerAppPlatform = Platform("Azure.ContainerApp", {
171
+ createRuntimeContext: (id): ContainerAppRuntimeContext => {
172
+ const env: Record<string, Output.Output> = {};
173
+
174
+ return {
175
+ Type: "Azure.ContainerApp",
176
+ id,
177
+ env,
178
+ set: (bindingId: string, output: Output.Output) =>
179
+ Effect.sync(() => {
180
+ const key = bindingId.replaceAll(/[^a-zA-Z0-9]/g, "_");
181
+ env[key] = output.pipe(Output.map((value) => JSON.stringify(value)));
182
+ return key;
183
+ }),
184
+ get: <T>(key: string) =>
185
+ Config.string(key)
186
+ .pipe(
187
+ Effect.flatMap((value) =>
188
+ Effect.try({
189
+ try: () => JSON.parse(value) as T,
190
+ catch: (error) => error as Error,
191
+ }),
192
+ ),
193
+ Effect.catch((cause) =>
194
+ Effect.die(new Error(`Failed to get environment variable: ${key}`, { cause })),
195
+ ),
196
+ ),
197
+ };
198
+ },
199
+ });
200
+
201
+ export const ContainerAppProvider = () =>
202
+ Provider.effect(
203
+ ContainerApp,
204
+ Effect.gen(function* () {
205
+ const clients = yield* makeAzureClients;
206
+ const names = yield* makePhysicalNames;
207
+
208
+ const containerAppName = (id: string, instanceId: string, name?: string) =>
209
+ names.physicalName(id, instanceId, name, {
210
+ maxLength: 32,
211
+ suffixLength: 6,
212
+ delimiter: "-",
213
+ lowercase: true,
214
+ sanitize: (value) =>
215
+ value
216
+ .replaceAll(/[^a-z0-9-]/g, "-")
217
+ .replaceAll(/^-+|-+$/g, "")
218
+ .slice(0, 32),
219
+ });
220
+
221
+ return ContainerApp.Provider.of({
222
+ stables: ["name", "containerAppId", "resourceGroupName"],
223
+ list: () =>
224
+ Effect.gen(function* () {
225
+ const groups = yield* Effect.tryPromise({
226
+ try: () => collectAzurePages(clients.resources.resourceGroups.list()),
227
+ catch: (cause) => azureError({ operation: "list resource groups", cause }),
228
+ });
229
+ const apps = yield* Effect.forEach(
230
+ groups,
231
+ (group) => {
232
+ if (!group.name) return Effect.succeed([]);
233
+ return Effect.tryPromise({
234
+ try: () => collectAzurePages(clients.appContainers.containerApps.listByResourceGroup(group.name!)),
235
+ catch: (cause) => azureError({ operation: "list Container Apps", resource: group.name, cause }),
236
+ }).pipe(Effect.map((items) => items.map((item) => [group.name!, item] as const)));
237
+ },
238
+ { concurrency: 4 },
239
+ );
240
+ return apps
241
+ .flat()
242
+ .filter(([, app]) => app.tags?.["alchemy:logical-id"])
243
+ .map(([resourceGroupName, app]) =>
244
+ toAttributes(app, resourceGroupName, app.tags?.["alchemy:build-hash"]),
245
+ );
246
+ }),
247
+ diff: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
248
+ if (!isResolved(news)) return undefined;
249
+ if (!output) return undefined;
250
+ const name = containerAppName(id, instanceId, news.name);
251
+ const groupName = yield* resourceGroupName(news.resourceGroup);
252
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
253
+ const resolvedImage = yield* imageName(news.image);
254
+ const oldRegistry = yield* registryDiffState(olds);
255
+ const newRegistry = yield* registryDiffState(news);
256
+ if (
257
+ name !== output.name ||
258
+ groupName !== output.resourceGroupName ||
259
+ location !== output.location ||
260
+ (yield* environmentId(news.environment)) !== output.environmentId
261
+ ) {
262
+ return name !== output.name ||
263
+ groupName !== output.resourceGroupName ||
264
+ location !== output.location ||
265
+ (yield* environmentId(news.environment)) !== output.environmentId
266
+ ? ({ action: "replace" } as const)
267
+ : ({ action: "update" } as const);
268
+ }
269
+ if (!sameDiffValue({
270
+ image: yield* imageName(olds.image),
271
+ registry: oldRegistry,
272
+ buildHash: olds.buildHash,
273
+ targetPort: olds.targetPort ?? 3000,
274
+ external: olds.external ?? true,
275
+ env: olds.env ?? {},
276
+ cpu: olds.cpu ?? 0.5,
277
+ memory: olds.memory ?? "1Gi",
278
+ minReplicas: olds.minReplicas ?? 0,
279
+ maxReplicas: olds.maxReplicas ?? 1,
280
+ containerName: olds.containerName ?? "app",
281
+ tags: olds.tags ?? {},
282
+ }, {
283
+ image: resolvedImage,
284
+ registry: newRegistry,
285
+ buildHash: news.buildHash,
286
+ targetPort: news.targetPort ?? 3000,
287
+ external: news.external ?? true,
288
+ env: news.env ?? {},
289
+ cpu: news.cpu ?? 0.5,
290
+ memory: news.memory ?? "1Gi",
291
+ minReplicas: news.minReplicas ?? 0,
292
+ maxReplicas: news.maxReplicas ?? 1,
293
+ containerName: news.containerName ?? "app",
294
+ tags: news.tags ?? {},
295
+ })) {
296
+ return { action: "update" } as const;
297
+ }
298
+ return undefined;
299
+ }),
300
+ read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) {
301
+ const groupName =
302
+ output?.resourceGroupName ??
303
+ (olds ? yield* resourceGroupName(olds.resourceGroup) : undefined);
304
+ if (!groupName) return undefined;
305
+ const name = output?.name ?? containerAppName(id, instanceId, olds?.name);
306
+ const app = yield* Effect.tryPromise({
307
+ try: () => clients.appContainers.containerApps.get(groupName, name),
308
+ catch: (cause) => azureError({ operation: "read Container App", resource: name, cause }),
309
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
310
+ if (!app) return undefined;
311
+ const attrs = toAttributes(app, groupName, output?.buildHash ?? olds?.buildHash);
312
+ return hasAlchemyTags(id, app.tags) ? attrs : Unowned(attrs);
313
+ }),
314
+ reconcile: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
315
+ const name = containerAppName(id, instanceId, news.name);
316
+ validateContainerAppName(name);
317
+ if (news.providerRegistration) {
318
+ yield* resolveResourceValue(news.providerRegistration.namespace);
319
+ }
320
+ const groupName = yield* resourceGroupName(news.resourceGroup);
321
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
322
+ const targetPort = news.targetPort ?? 3000;
323
+ const resolvedEnvironmentId = yield* environmentId(news.environment);
324
+ const resolvedImage = yield* imageName(news.image);
325
+ const registry = yield* registryPullCredentials(news);
326
+ const env = materializeContainerEnv(news.env ?? {});
327
+ const secrets = [
328
+ ...(registry.password
329
+ ? [{ name: "registry-password", value: String(Redacted.value(registry.password)) }]
330
+ : []),
331
+ ...env.secrets,
332
+ ];
333
+ if (output && olds) {
334
+ const existing = yield* Effect.tryPromise({
335
+ try: () => clients.appContainers.containerApps.get(groupName, name),
336
+ catch: (cause) =>
337
+ azureError({ operation: "read Container App before update", resource: name, cause }),
338
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
339
+ if (existing && hasAlchemyTags(id, output.tags) && !hasAlchemyTags(id, existing.tags)) {
340
+ throw new Error(`Cannot adopt resource "${name}" without --adopt.`);
341
+ }
342
+ }
343
+ const app = yield* Effect.tryPromise({
344
+ try: () =>
345
+ clients.appContainers.containerApps.beginCreateOrUpdateAndWait(groupName, name, {
346
+ location,
347
+ environmentId: resolvedEnvironmentId,
348
+ tags: withAlchemyTags(id, {
349
+ ...news.tags,
350
+ ...(news.buildHash ? { "alchemy:build-hash": news.buildHash } : {}),
351
+ }),
352
+ configuration: {
353
+ activeRevisionsMode: "Single",
354
+ secrets: secrets.length > 0 ? secrets : undefined,
355
+ registries:
356
+ registry.server && registry.username && registry.password
357
+ ? [
358
+ {
359
+ server: registry.server,
360
+ username: registry.username,
361
+ passwordSecretRef: "registry-password",
362
+ },
363
+ ]
364
+ : undefined,
365
+ ingress: {
366
+ external: news.external ?? true,
367
+ targetPort,
368
+ transport: "auto",
369
+ traffic: [{ latestRevision: true, weight: 100 }],
370
+ },
371
+ },
372
+ template: {
373
+ revisionSuffix: news.buildHash
374
+ ? `r${news.buildHash.slice(0, 10).toLowerCase()}`
375
+ : undefined,
376
+ containers: [
377
+ {
378
+ name: news.containerName ?? "app",
379
+ image: resolvedImage,
380
+ env: env.entries,
381
+ resources: {
382
+ cpu: news.cpu ?? 0.5,
383
+ memory: news.memory ?? "1Gi",
384
+ },
385
+ },
386
+ ],
387
+ scale: {
388
+ minReplicas: news.minReplicas ?? 0,
389
+ maxReplicas: news.maxReplicas ?? 1,
390
+ },
391
+ },
392
+ }),
393
+ catch: (cause) =>
394
+ azureError({
395
+ operation: "reconcile Container App",
396
+ resource: name,
397
+ cause,
398
+ }),
399
+ }).pipe(withHeartbeat(`Container App "${name}"`));
400
+ return toAttributes(app, groupName, news.buildHash);
401
+ }),
402
+ delete: Effect.fnUntraced(function* ({ olds, output, session }) {
403
+ if (olds.delete === false) return;
404
+ yield* session.note(`Deleting Azure Container App: ${output.name}`);
405
+ yield* Effect.tryPromise({
406
+ try: () =>
407
+ clients.appContainers.containerApps.beginDeleteAndWait(
408
+ output.resourceGroupName,
409
+ output.name,
410
+ ),
411
+ catch: (cause) =>
412
+ azureError({ operation: "delete Container App", resource: output.name, cause }),
413
+ }).pipe(
414
+ withHeartbeat(`deleting Container App "${output.name}"`),
415
+ Effect.catchIf(isNotFound, () => Effect.void),
416
+ );
417
+ }),
418
+ });
419
+ }),
420
+ );
421
+
422
+ function toAttributes(
423
+ app: AzureContainerApp,
424
+ resourceGroupName: string,
425
+ buildHash?: string,
426
+ ): ContainerApp["Attributes"] {
427
+ if (!app.name || !app.id || !app.location || !app.environmentId) {
428
+ throw new Error("Azure returned an incomplete Container App response");
429
+ }
430
+ const image = app.template?.containers?.[0]?.image;
431
+ if (!image) {
432
+ throw new Error("Azure returned a Container App response without a container image");
433
+ }
434
+ const targetPort = app.configuration?.ingress?.targetPort ?? 3000;
435
+ const fqdn = app.configuration?.ingress?.fqdn ?? app.latestRevisionFqdn;
436
+ return {
437
+ name: app.name,
438
+ resourceGroupName,
439
+ location: app.location,
440
+ containerAppId: app.id,
441
+ environmentId: app.environmentId,
442
+ image,
443
+ buildHash: buildHash ?? app.tags?.["alchemy:build-hash"],
444
+ targetPort,
445
+ fqdn,
446
+ url: fqdn ? `https://${fqdn}` : undefined,
447
+ latestRevisionName: app.latestRevisionName,
448
+ latestReadyRevisionName: app.latestReadyRevisionName,
449
+ provisioningState: app.provisioningState,
450
+ runningStatus: app.runningStatus,
451
+ tags: app.tags,
452
+ };
453
+ }
454
+
455
+ function stringifyEnvValue(value: unknown) {
456
+ if (typeof value === "string") return value;
457
+ return JSON.stringify(value);
458
+ }
459
+
460
+ function materializeContainerEnv(env: Record<string, unknown>) {
461
+ const secrets: Array<{ name: string; value: string }> = [];
462
+ const entries = Object.entries(env).map(([name, value]) => {
463
+ if (!Redacted.isRedacted(value)) return { name, value: stringifyEnvValue(value) };
464
+ const slug = name.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-").replaceAll(/^-+|-+$/g, "") || "value";
465
+ const suffix = stableShortHash(name);
466
+ const secretName = `env-${slug.slice(0, 64 - suffix.length - 5)}-${suffix}`;
467
+ secrets.push({ name: secretName, value: String(Redacted.value(value)) });
468
+ return { name, secretRef: secretName };
469
+ });
470
+ return { entries, secrets };
471
+ }
472
+
473
+ function stableShortHash(value: string) {
474
+ let hash = 5381;
475
+ for (const char of value) hash = ((hash * 33) ^ char.charCodeAt(0)) >>> 0;
476
+ return hash.toString(36).slice(0, 6);
477
+ }
478
+
479
+ function sameDiffValue(left: unknown, right: unknown) {
480
+ return JSON.stringify(stableDiffValue(left)) === JSON.stringify(stableDiffValue(right));
481
+ }
482
+
483
+ function stableDiffValue(value: unknown): unknown {
484
+ if (Redacted.isRedacted(value)) return Redacted.value(value);
485
+ if (Array.isArray(value)) return value.map(stableDiffValue);
486
+ if (value && typeof value === "object") {
487
+ const record = value as Record<string, unknown>;
488
+ return Object.fromEntries(
489
+ Object.keys(record)
490
+ .filter((key) => record[key] !== undefined)
491
+ .sort()
492
+ .map((key) => [key, stableDiffValue(record[key])]),
493
+ );
494
+ }
495
+ return value;
496
+ }
497
+
498
+ function environmentId(environment: string | ContainerAppEnvironment) {
499
+ return Effect.gen(function* () {
500
+ if (typeof environment === "string") return environment;
501
+ return yield* resolveResourceValue(environment.environmentId);
502
+ });
503
+ }
504
+
505
+ function imageName(image: string | ContainerImage) {
506
+ return Effect.gen(function* () {
507
+ if (typeof image === "string") return image;
508
+ return yield* resolveResourceValue(image.image);
509
+ });
510
+ }
511
+
512
+ function registryPullCredentials(props: ContainerAppProps) {
513
+ return Effect.gen(function* () {
514
+ if (props.registryUsername && props.registryPassword) {
515
+ return {
516
+ server: props.registry ? yield* registryLoginServer(props.registry) : undefined,
517
+ username: props.registryUsername,
518
+ password:
519
+ typeof props.registryPassword === "string"
520
+ ? Redacted.make(props.registryPassword)
521
+ : props.registryPassword,
522
+ };
523
+ }
524
+ if (!props.registry) return {};
525
+ const server = yield* registryLoginServer(props.registry);
526
+ const username = yield* resolveResourceValue(props.registry.username);
527
+ const password = yield* resolveResourceValue(props.registry.password);
528
+ return { server, username, password };
529
+ });
530
+ }
531
+
532
+ function registryDiffState(props: ContainerAppProps) {
533
+ return Effect.gen(function* () {
534
+ const credentials = yield* registryPullCredentials(props);
535
+ return {
536
+ server: credentials.server,
537
+ username: credentials.username,
538
+ password: credentials.password ? Redacted.value(credentials.password) : undefined,
539
+ };
540
+ });
541
+ }
542
+
543
+ function registryLoginServer(registry: ContainerRegistry) {
544
+ return Effect.gen(function* () {
545
+ return yield* resolveResourceValue(registry.loginServer);
546
+ });
547
+ }
548
+
549
+ function validateContainerAppName(name: string) {
550
+ if (!/^[a-z][a-z0-9-]{0,30}[a-z0-9]$/.test(name)) {
551
+ throw new Error(
552
+ `Azure Container App name "${name}" is invalid. It must be 2-32 characters, start with a lowercase letter, end with a lowercase letter or number, and contain only lowercase letters, numbers, and hyphens.`,
553
+ );
554
+ }
555
+ }