@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,253 @@
1
+ import type { ManagedEnvironment } from "@azure/arm-appcontainers";
2
+ import * as Effect from "effect/Effect";
3
+ import { Unowned } from "alchemy/AdoptPolicy";
4
+ import { isResolved } from "alchemy/Diff";
5
+ import { Resource } from "alchemy";
6
+ import * as Provider from "alchemy/Provider";
7
+ import { makeAzureClients } from "./Clients.ts";
8
+ import { azureError, isNotFound } from "./Errors.ts";
9
+ import { collectAzurePages, diffValueEqual, makePhysicalNames, requireLocation, resourceGroupName, resolveResourceValue, withHeartbeat } from "./Internal.ts";
10
+ import type { Providers } from "./Providers.ts";
11
+ import type { ResourceProviderRegistration } from "./ResourceProviderRegistration.ts";
12
+ import type { ResourceGroup } from "./ResourceGroup.ts";
13
+ import { hasAlchemyTags, withAlchemyTags } from "./ResourceGroup.ts";
14
+
15
+ export interface ContainerAppEnvironmentProps {
16
+ /** Managed environment name. Defaults to a deterministic Azure-safe physical name. */
17
+ name?: string;
18
+ /** Resource group object or name containing the managed environment. */
19
+ resourceGroup: string | ResourceGroup;
20
+ /** Azure region. Defaults to the resource group's location when a ResourceGroup object is supplied. */
21
+ location?: string;
22
+ /** Log Analytics workspace customer ID for Container Apps logs. */
23
+ logAnalyticsCustomerId?: string;
24
+ /** Log Analytics shared key for Container Apps logs. */
25
+ logAnalyticsSharedKey?: string;
26
+ /** Whether the environment is zone redundant. */
27
+ zoneRedundant?: boolean;
28
+ /** Tags to apply to the managed environment. */
29
+ tags?: Record<string, string>;
30
+ /** Optional provider registration dependency, usually `Microsoft.App`. */
31
+ providerRegistration?: ResourceProviderRegistration;
32
+ /** Whether to delete the managed environment when removed from Alchemy. @default true */
33
+ delete?: boolean;
34
+ }
35
+
36
+ export type ContainerAppEnvironment = Resource<
37
+ "Azure.ContainerAppEnvironment",
38
+ ContainerAppEnvironmentProps,
39
+ {
40
+ name: string;
41
+ resourceGroupName: string;
42
+ location: string;
43
+ environmentId: string;
44
+ defaultDomain?: string;
45
+ staticIp?: string;
46
+ provisioningState?: string;
47
+ tags?: Record<string, string>;
48
+ },
49
+ never,
50
+ Providers
51
+ >;
52
+
53
+ /**
54
+ * Azure Container Apps managed environment.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const environment = yield* Azure.ContainerAppEnvironment("Env", {
59
+ * resourceGroup: group,
60
+ * });
61
+ * ```
62
+ */
63
+ export const ContainerAppEnvironment = Resource<ContainerAppEnvironment>(
64
+ "Azure.ContainerAppEnvironment",
65
+ );
66
+
67
+ export const ContainerAppEnvironmentProvider = () =>
68
+ Provider.effect(
69
+ ContainerAppEnvironment,
70
+ Effect.gen(function* () {
71
+ const clients = yield* makeAzureClients;
72
+ const names = yield* makePhysicalNames;
73
+
74
+ const environmentName = (id: string, instanceId: string, name?: string) =>
75
+ names.physicalName(id, instanceId, name, {
76
+ maxLength: 32,
77
+ suffixLength: 6,
78
+ delimiter: "-",
79
+ lowercase: true,
80
+ sanitize: (value) =>
81
+ value
82
+ .replaceAll(/[^a-z0-9-]/g, "-")
83
+ .replaceAll(/^-+|-+$/g, "")
84
+ .slice(0, 32),
85
+ });
86
+
87
+ return ContainerAppEnvironment.Provider.of({
88
+ stables: ["name", "environmentId", "resourceGroupName"],
89
+ list: () =>
90
+ Effect.gen(function* () {
91
+ const groups = yield* Effect.tryPromise({
92
+ try: () => collectAzurePages(clients.resources.resourceGroups.list()),
93
+ catch: (cause) => azureError({ operation: "list resource groups", cause }),
94
+ });
95
+ const environments = yield* Effect.forEach(
96
+ groups,
97
+ (group) => {
98
+ if (!group.name) return Effect.succeed([]);
99
+ return Effect.tryPromise({
100
+ try: () => collectAzurePages(
101
+ clients.appContainers.managedEnvironments.listByResourceGroup(group.name!),
102
+ ),
103
+ catch: (cause) =>
104
+ azureError({ operation: "list Container Apps managed environments", resource: group.name, cause }),
105
+ }).pipe(Effect.map((items) => items.map((item) => [group.name!, item] as const)));
106
+ },
107
+ { concurrency: 4 },
108
+ );
109
+ return environments
110
+ .flat()
111
+ .filter(([, environment]) => environment.tags?.["alchemy:logical-id"])
112
+ .map(([resourceGroupName, environment]) => toAttributes(environment, resourceGroupName));
113
+ }),
114
+ diff: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
115
+ if (!isResolved(news)) return undefined;
116
+ if (!output) return undefined;
117
+ const name = environmentName(id, instanceId, news.name);
118
+ const groupName = yield* resourceGroupName(news.resourceGroup);
119
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
120
+ if (
121
+ name !== output.name ||
122
+ groupName !== output.resourceGroupName ||
123
+ location !== output.location
124
+ ) {
125
+ return { action: "replace" } as const;
126
+ }
127
+ if ((olds.zoneRedundant ?? false) !== (news.zoneRedundant ?? false)) {
128
+ return { action: "replace" } as const;
129
+ }
130
+ if (!diffValueEqual({
131
+ logAnalyticsCustomerId: olds.logAnalyticsCustomerId,
132
+ logAnalyticsSharedKey: olds.logAnalyticsSharedKey,
133
+ tags: olds.tags ?? {},
134
+ }, {
135
+ logAnalyticsCustomerId: news.logAnalyticsCustomerId,
136
+ logAnalyticsSharedKey: news.logAnalyticsSharedKey,
137
+ tags: news.tags ?? {},
138
+ })) {
139
+ return { action: "update" } as const;
140
+ }
141
+ return undefined;
142
+ }),
143
+ read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) {
144
+ const groupName =
145
+ output?.resourceGroupName ??
146
+ (olds ? yield* resourceGroupName(olds.resourceGroup) : undefined);
147
+ if (!groupName) return undefined;
148
+ const name = output?.name ?? environmentName(id, instanceId, olds?.name);
149
+ const environment = yield* Effect.tryPromise({
150
+ try: () => clients.appContainers.managedEnvironments.get(groupName, name),
151
+ catch: (cause) => azureError({ operation: "read Container Apps managed environment", resource: name, cause }),
152
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
153
+ if (!environment) return undefined;
154
+ const attrs = toAttributes(environment, groupName);
155
+ return hasAlchemyTags(id, environment.tags) ? attrs : Unowned(attrs);
156
+ }),
157
+ reconcile: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
158
+ const name = environmentName(id, instanceId, news.name);
159
+ validateEnvironmentName(name);
160
+ if (news.providerRegistration) {
161
+ yield* resolveResourceValue(news.providerRegistration.namespace);
162
+ }
163
+ const groupName = yield* resourceGroupName(news.resourceGroup);
164
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
165
+ if (output && olds) {
166
+ const existing = yield* Effect.tryPromise({
167
+ try: () => clients.appContainers.managedEnvironments.get(groupName, name),
168
+ catch: (cause) =>
169
+ azureError({
170
+ operation: "read Container Apps managed environment before update",
171
+ resource: name,
172
+ cause,
173
+ }),
174
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
175
+ if (existing && hasAlchemyTags(id, output.tags) && !hasAlchemyTags(id, existing.tags)) {
176
+ throw new Error(`Cannot adopt resource "${name}" without --adopt.`);
177
+ }
178
+ }
179
+ const environment = yield* Effect.tryPromise({
180
+ try: () =>
181
+ clients.appContainers.managedEnvironments.beginCreateOrUpdateAndWait(
182
+ groupName,
183
+ name,
184
+ {
185
+ location,
186
+ zoneRedundant: news.zoneRedundant ?? false,
187
+ tags: withAlchemyTags(id, news.tags),
188
+ appLogsConfiguration:
189
+ news.logAnalyticsCustomerId && news.logAnalyticsSharedKey
190
+ ? {
191
+ destination: "log-analytics",
192
+ logAnalyticsConfiguration: {
193
+ customerId: news.logAnalyticsCustomerId,
194
+ sharedKey: news.logAnalyticsSharedKey,
195
+ },
196
+ }
197
+ : undefined,
198
+ },
199
+ ),
200
+ catch: (cause) =>
201
+ azureError({
202
+ operation: "reconcile Container Apps managed environment",
203
+ resource: name,
204
+ cause,
205
+ }),
206
+ }).pipe(withHeartbeat(`Container Apps managed environment "${name}"`));
207
+ return toAttributes(environment, groupName);
208
+ }),
209
+ delete: Effect.fnUntraced(function* ({ olds, output, session }) {
210
+ if (olds.delete === false) return;
211
+ yield* session.note(`Deleting Azure Container Apps managed environment: ${output.name}`);
212
+ yield* Effect.tryPromise({
213
+ try: () => clients.appContainers.managedEnvironments.beginDeleteAndWait(
214
+ output.resourceGroupName,
215
+ output.name,
216
+ ),
217
+ catch: (cause) =>
218
+ azureError({ operation: "delete Container Apps managed environment", resource: output.name, cause }),
219
+ }).pipe(
220
+ withHeartbeat(`deleting Container Apps managed environment "${output.name}"`),
221
+ Effect.catchIf(isNotFound, () => Effect.void),
222
+ );
223
+ }),
224
+ });
225
+ }),
226
+ );
227
+
228
+ function toAttributes(
229
+ environment: ManagedEnvironment,
230
+ resourceGroupName: string,
231
+ ): ContainerAppEnvironment["Attributes"] {
232
+ if (!environment.name || !environment.id || !environment.location) {
233
+ throw new Error("Azure returned an incomplete Container Apps managed environment response");
234
+ }
235
+ return {
236
+ name: environment.name,
237
+ resourceGroupName,
238
+ location: environment.location,
239
+ environmentId: environment.id,
240
+ defaultDomain: environment.defaultDomain,
241
+ staticIp: environment.staticIp,
242
+ provisioningState: environment.provisioningState,
243
+ tags: environment.tags,
244
+ };
245
+ }
246
+
247
+ function validateEnvironmentName(name: string) {
248
+ if (!/^[a-z][a-z0-9-]{0,30}[a-z0-9]$/.test(name)) {
249
+ throw new Error(
250
+ `Azure Container Apps managed environment 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.`,
251
+ );
252
+ }
253
+ }
@@ -0,0 +1,181 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Redacted from "effect/Redacted";
4
+ import { isResolved } from "alchemy/Diff";
5
+ import { Resource } from "alchemy";
6
+ import * as Provider from "alchemy/Provider";
7
+ import { resolveResourceValue } from "./Internal.ts";
8
+ import type { ContainerRegistry } from "./ContainerRegistry.ts";
9
+ import type { Providers } from "./Providers.ts";
10
+
11
+ export interface ContainerImageProps {
12
+ /** Azure Container Registry object or login server. */
13
+ registry: string | ContainerRegistry;
14
+ /** Repository name inside the registry. Defaults to the logical id lowercased. */
15
+ repository?: string;
16
+ /** Image tag. @default buildHash ?? "latest" */
17
+ tag?: string;
18
+ /** Docker build context path. @default "." */
19
+ context?: string;
20
+ /** Dockerfile path, relative to context or absolute. */
21
+ dockerfile?: string;
22
+ /** Build hash from an external build step. Changing this rebuilds and pushes. */
23
+ buildHash?: string;
24
+ /** Docker build arguments. */
25
+ buildArgs?: Record<string, string>;
26
+ /** Docker platform. Defaults to `linux/amd64` because Azure Container Apps requires an amd64 image. */
27
+ platform?: string;
28
+ /** Do not use Docker build cache. */
29
+ noCache?: boolean;
30
+ /** Registry username. Defaults to registry.username when a ContainerRegistry is supplied. */
31
+ username?: string;
32
+ /** Registry password. Defaults to registry.password when a ContainerRegistry is supplied. */
33
+ password?: string | Redacted.Redacted<string>;
34
+ }
35
+
36
+ export type ContainerImage = Resource<
37
+ "Azure.ContainerImage",
38
+ ContainerImageProps,
39
+ {
40
+ image: string;
41
+ loginServer: string;
42
+ repository: string;
43
+ tag: string;
44
+ buildHash?: string;
45
+ },
46
+ never,
47
+ Providers
48
+ >;
49
+
50
+ /**
51
+ * Builds a local Docker context and pushes it to Azure Container Registry.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const image = yield* Azure.ContainerImage("Image", {
56
+ * registry,
57
+ * context: ".",
58
+ * buildHash: build.hash,
59
+ * });
60
+ * ```
61
+ */
62
+ export const ContainerImage = Resource<ContainerImage>("Azure.ContainerImage");
63
+
64
+ export const ContainerImageProvider = () =>
65
+ Provider.effect(
66
+ ContainerImage,
67
+ Effect.gen(function* () {
68
+ return ContainerImage.Provider.of({
69
+ stables: ["image", "loginServer", "repository", "tag"],
70
+ list: () => Effect.succeed([]),
71
+ diff: Effect.fnUntraced(function* ({ id, news, output }) {
72
+ if (!isResolved(news)) return undefined;
73
+ if (!output) return undefined;
74
+ const desired = yield* desiredImage(id, news);
75
+ if (desired.image !== output.image || desired.buildHash !== output.buildHash) {
76
+ return { action: "update" } as const;
77
+ }
78
+ return undefined;
79
+ }),
80
+ read: Effect.fnUntraced(function* ({ output }) {
81
+ return output;
82
+ }),
83
+ reconcile: Effect.fnUntraced(function* ({ id, news, session }) {
84
+ const desired = yield* desiredImage(id, news);
85
+ const credentials = yield* registryCredentials(news);
86
+ if (credentials.username && credentials.password) {
87
+ yield* session.note(`Logging in to Azure Container Registry: ${desired.loginServer}`);
88
+ yield* run(
89
+ "docker",
90
+ [
91
+ "login",
92
+ desired.loginServer,
93
+ "--username",
94
+ credentials.username,
95
+ "--password-stdin",
96
+ ],
97
+ Redacted.value(credentials.password),
98
+ );
99
+ }
100
+ yield* session.note(`Building container image: ${desired.image}`);
101
+ yield* run("docker", dockerBuildArgs(news, desired.image));
102
+ yield* session.note(`Pushing container image: ${desired.image}`);
103
+ yield* run("docker", ["push", desired.image]);
104
+ return desired;
105
+ }),
106
+ delete: Effect.fnUntraced(function* () {
107
+ // Images are immutable build artifacts in ACR; deleting tags here would be surprising.
108
+ }),
109
+ });
110
+ }),
111
+ );
112
+
113
+ function desiredImage(id: string, props: ContainerImageProps) {
114
+ return Effect.gen(function* () {
115
+ const loginServer = yield* registryLoginServer(props.registry);
116
+ const repository = props.repository ?? id.toLowerCase().replaceAll(/[^a-z0-9._/-]/g, "-");
117
+ const tag = props.tag ?? props.buildHash?.slice(0, 40) ?? "latest";
118
+ return {
119
+ image: `${loginServer}/${repository}:${tag}`,
120
+ loginServer,
121
+ repository,
122
+ tag,
123
+ buildHash: props.buildHash,
124
+ } satisfies ContainerImage["Attributes"];
125
+ });
126
+ }
127
+
128
+ function registryLoginServer(registry: string | ContainerRegistry) {
129
+ return Effect.gen(function* () {
130
+ if (typeof registry === "string") return registry;
131
+ return yield* resolveResourceValue(registry.loginServer);
132
+ });
133
+ }
134
+
135
+ function registryCredentials(props: ContainerImageProps) {
136
+ return Effect.gen(function* () {
137
+ if (props.username && props.password) {
138
+ return {
139
+ username: props.username,
140
+ password:
141
+ typeof props.password === "string" ? Redacted.make(props.password) : props.password,
142
+ };
143
+ }
144
+ if (typeof props.registry === "string") return {};
145
+ const username = yield* resolveResourceValue(props.registry.username);
146
+ const password = yield* resolveResourceValue(props.registry.password);
147
+ return { username, password };
148
+ });
149
+ }
150
+
151
+ function dockerBuildArgs(props: ContainerImageProps, image: string) {
152
+ const args = ["build", props.context ?? ".", "--tag", image];
153
+ if (props.dockerfile) args.push("--file", props.dockerfile);
154
+ args.push("--platform", props.platform ?? "linux/amd64");
155
+ if (props.noCache) args.push("--no-cache");
156
+ for (const [key, value] of Object.entries(props.buildArgs ?? {})) {
157
+ args.push("--build-arg", `${key}=${value}`);
158
+ }
159
+ return args;
160
+ }
161
+
162
+ function run(command: string, args: string[], stdin?: string) {
163
+ return Effect.tryPromise({
164
+ try: () =>
165
+ new Promise<void>((resolve, reject) => {
166
+ const child = spawn(command, args, {
167
+ stdio: [stdin ? "pipe" : "ignore", "inherit", "inherit"],
168
+ });
169
+ child.on("error", reject);
170
+ child.on("close", (code) => {
171
+ if (code === 0) resolve();
172
+ else reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`));
173
+ });
174
+ if (stdin && child.stdin) {
175
+ child.stdin.write(stdin);
176
+ child.stdin.end();
177
+ }
178
+ }),
179
+ catch: (cause) => new Error(`Failed to run ${command} ${args.join(" ")}`, { cause }),
180
+ });
181
+ }
@@ -0,0 +1,232 @@
1
+ import type { Registry } from "@azure/arm-containerregistry";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Redacted from "effect/Redacted";
4
+ import { Unowned } from "alchemy/AdoptPolicy";
5
+ import { isResolved } from "alchemy/Diff";
6
+ import { Resource } from "alchemy";
7
+ import * as Provider from "alchemy/Provider";
8
+ import { makeAzureClients } from "./Clients.ts";
9
+ import { azureError, isNotFound } from "./Errors.ts";
10
+ import { collectAzurePages, diffValueEqual, makePhysicalNames, requireLocation, resourceGroupName } from "./Internal.ts";
11
+ import type { Providers } from "./Providers.ts";
12
+ import type { ResourceGroup } from "./ResourceGroup.ts";
13
+ import { hasAlchemyTags, withAlchemyTags } from "./ResourceGroup.ts";
14
+
15
+ export interface ContainerRegistryProps {
16
+ /** Registry name. Must be globally unique, 5-50 alphanumeric characters. */
17
+ name?: string;
18
+ /** Resource group object or name containing the registry. */
19
+ resourceGroup: string | ResourceGroup;
20
+ /** Azure region. Defaults to the resource group's location when a ResourceGroup object is supplied. */
21
+ location?: string;
22
+ /** Container Registry SKU. @default "Basic" */
23
+ sku?: "Basic" | "Standard" | "Premium";
24
+ /** Enable admin credentials so Docker can push images. @default true */
25
+ adminUserEnabled?: boolean;
26
+ /** Tags to apply to the registry. */
27
+ tags?: Record<string, string>;
28
+ /** Whether to delete the registry when removed from Alchemy. @default true */
29
+ delete?: boolean;
30
+ }
31
+
32
+ export type ContainerRegistry = Resource<
33
+ "Azure.ContainerRegistry",
34
+ ContainerRegistryProps,
35
+ {
36
+ name: string;
37
+ resourceGroupName: string;
38
+ location: string;
39
+ registryId: string;
40
+ loginServer: string;
41
+ username?: string;
42
+ password?: Redacted.Redacted<string>;
43
+ sku: string;
44
+ provisioningState?: string;
45
+ tags?: Record<string, string>;
46
+ },
47
+ never,
48
+ Providers
49
+ >;
50
+
51
+ /**
52
+ * Azure Container Registry for storing container images.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * const registry = yield* Azure.ContainerRegistry("Registry", {
57
+ * resourceGroup: group,
58
+ * });
59
+ * ```
60
+ */
61
+ export const ContainerRegistry = Resource<ContainerRegistry>("Azure.ContainerRegistry");
62
+
63
+ export const ContainerRegistryProvider = () =>
64
+ Provider.effect(
65
+ ContainerRegistry,
66
+ Effect.gen(function* () {
67
+ const clients = yield* makeAzureClients;
68
+ const names = yield* makePhysicalNames;
69
+
70
+ const registryName = (id: string, instanceId: string, name?: string) =>
71
+ names.physicalName(id, instanceId, name, {
72
+ maxLength: 50,
73
+ suffixLength: 8,
74
+ delimiter: "",
75
+ lowercase: true,
76
+ sanitize: (value) => value.replaceAll(/[^a-z0-9]/g, "").slice(0, 50),
77
+ });
78
+
79
+ return ContainerRegistry.Provider.of({
80
+ stables: ["name", "registryId", "resourceGroupName", "loginServer"],
81
+ list: () =>
82
+ Effect.gen(function* () {
83
+ const groups = yield* Effect.tryPromise({
84
+ try: () => collectAzurePages(clients.resources.resourceGroups.list()),
85
+ catch: (cause) => azureError({ operation: "list resource groups", cause }),
86
+ });
87
+ const registries = yield* Effect.forEach(
88
+ groups,
89
+ (group) => {
90
+ if (!group.name) return Effect.succeed([]);
91
+ return Effect.tryPromise({
92
+ try: () => collectAzurePages(clients.containerRegistry.registries.listByResourceGroup(group.name!)),
93
+ catch: (cause) =>
94
+ azureError({ operation: "list Container Registries", resource: group.name, cause }),
95
+ }).pipe(Effect.map((items) => items.map((item) => [group.name!, item] as const)));
96
+ },
97
+ { concurrency: 4 },
98
+ );
99
+ return yield* Effect.forEach(
100
+ registries.flat(),
101
+ ([resourceGroupName, registry]) =>
102
+ registry.tags?.["alchemy:logical-id"]
103
+ ? toAttributes(registry, resourceGroupName)
104
+ : Effect.succeed(undefined),
105
+ { concurrency: 4 },
106
+ ).pipe(Effect.map((items) => items.filter((item) => item !== undefined)));
107
+ }),
108
+ diff: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
109
+ if (!isResolved(news)) return undefined;
110
+ if (!output) return undefined;
111
+ const name = registryName(id, instanceId, news.name);
112
+ const groupName = yield* resourceGroupName(news.resourceGroup);
113
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
114
+ if (
115
+ name !== output.name ||
116
+ groupName !== output.resourceGroupName ||
117
+ location !== output.location
118
+ ) {
119
+ return { action: "replace" } as const;
120
+ }
121
+ if (!diffValueEqual({
122
+ sku: olds.sku ?? "Basic",
123
+ adminUserEnabled: olds.adminUserEnabled ?? true,
124
+ tags: olds.tags ?? {},
125
+ }, {
126
+ sku: news.sku ?? "Basic",
127
+ adminUserEnabled: news.adminUserEnabled ?? true,
128
+ tags: news.tags ?? {},
129
+ })) {
130
+ return { action: "update" } as const;
131
+ }
132
+ return undefined;
133
+ }),
134
+ read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) {
135
+ const groupName =
136
+ output?.resourceGroupName ??
137
+ (olds ? yield* resourceGroupName(olds.resourceGroup) : undefined);
138
+ if (!groupName) return undefined;
139
+ const name = output?.name ?? registryName(id, instanceId, olds?.name);
140
+ const registry = yield* Effect.tryPromise({
141
+ try: () => clients.containerRegistry.registries.get(groupName, name),
142
+ catch: (cause) => azureError({ operation: "read Container Registry", resource: name, cause }),
143
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
144
+ if (!registry) return undefined;
145
+ const attrs = yield* toAttributes(registry, groupName);
146
+ return hasAlchemyTags(id, registry.tags) ? attrs : Unowned(attrs);
147
+ }),
148
+ reconcile: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
149
+ const name = registryName(id, instanceId, news.name);
150
+ validateRegistryName(name);
151
+ const groupName = yield* resourceGroupName(news.resourceGroup);
152
+ const location = yield* requireLocation(id, news.location, news.resourceGroup);
153
+ if (output && olds) {
154
+ const existing = yield* Effect.tryPromise({
155
+ try: () => clients.containerRegistry.registries.get(groupName, name),
156
+ catch: (cause) =>
157
+ azureError({ operation: "read Container Registry before update", resource: name, cause }),
158
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
159
+ if (existing && hasAlchemyTags(id, output.tags) && !hasAlchemyTags(id, existing.tags)) {
160
+ throw new Error(`Cannot adopt resource "${name}" without --adopt.`);
161
+ }
162
+ }
163
+ const registry = yield* Effect.tryPromise({
164
+ try: () =>
165
+ clients.containerRegistry.registries.beginCreateAndWait(groupName, name, {
166
+ location,
167
+ sku: { name: news.sku ?? "Basic" },
168
+ adminUserEnabled: news.adminUserEnabled ?? true,
169
+ tags: withAlchemyTags(id, news.tags),
170
+ }),
171
+ catch: (cause) =>
172
+ azureError({
173
+ operation: "reconcile Container Registry",
174
+ resource: name,
175
+ cause,
176
+ }),
177
+ });
178
+ return yield* toAttributes(registry, groupName);
179
+ }),
180
+ delete: Effect.fnUntraced(function* ({ olds, output, session }) {
181
+ if (olds.delete === false) return;
182
+ yield* session.note(`Deleting Azure Container Registry: ${output.name}`);
183
+ yield* Effect.tryPromise({
184
+ try: () => clients.containerRegistry.registries.beginDeleteAndWait(
185
+ output.resourceGroupName,
186
+ output.name,
187
+ ),
188
+ catch: (cause) => azureError({ operation: "delete Container Registry", resource: output.name, cause }),
189
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.void));
190
+ }),
191
+ });
192
+
193
+ function toAttributes(registry: Registry, resourceGroupName: string) {
194
+ return Effect.gen(function* () {
195
+ if (!registry.name || !registry.id || !registry.location || !registry.loginServer) {
196
+ throw new Error("Azure returned an incomplete Container Registry response");
197
+ }
198
+ const credentials = registry.adminUserEnabled
199
+ ? yield* Effect.tryPromise({
200
+ try: () => clients.containerRegistry.registries.listCredentials(
201
+ resourceGroupName,
202
+ registry.name!,
203
+ ),
204
+ catch: (cause) =>
205
+ azureError({ operation: "list Container Registry credentials", resource: registry.name, cause }),
206
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)))
207
+ : undefined;
208
+ const password = credentials?.passwords?.find((item) => item.name === "password")?.value;
209
+ return {
210
+ name: registry.name,
211
+ resourceGroupName,
212
+ location: registry.location,
213
+ registryId: registry.id,
214
+ loginServer: registry.loginServer,
215
+ username: credentials?.username,
216
+ password: password ? Redacted.make(password) : undefined,
217
+ sku: registry.sku.name,
218
+ provisioningState: registry.provisioningState,
219
+ tags: registry.tags,
220
+ } satisfies ContainerRegistry["Attributes"];
221
+ });
222
+ }
223
+ }),
224
+ );
225
+
226
+ function validateRegistryName(name: string) {
227
+ if (!/^[a-z0-9]{5,50}$/.test(name)) {
228
+ throw new Error(
229
+ `Azure Container Registry name "${name}" is invalid. It must be 5-50 lowercase letters or numbers.`,
230
+ );
231
+ }
232
+ }