@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,292 @@
1
+ import type { StorageAccount as AzureStorageAccount } from "@azure/arm-storage";
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, resolveResourceValue } 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 StorageAccountProps {
16
+ /**
17
+ * Storage account name. Must be globally unique, 3-24 chars, lowercase letters and numbers.
18
+ */
19
+ name?: string;
20
+ /**
21
+ * Resource group object or name containing the storage account.
22
+ */
23
+ resourceGroup: string | ResourceGroup;
24
+ /**
25
+ * Azure region. Defaults to the resource group's location when a ResourceGroup object is supplied.
26
+ */
27
+ location?: string;
28
+ /** @default "Standard_LRS" */
29
+ sku?:
30
+ | "Standard_LRS"
31
+ | "Standard_GRS"
32
+ | "Standard_RAGRS"
33
+ | "Standard_ZRS"
34
+ | "Premium_LRS"
35
+ | "Premium_ZRS";
36
+ /** @default "StorageV2" */
37
+ kind?: "StorageV2" | "BlobStorage" | "BlockBlobStorage" | "FileStorage";
38
+ /** @default "Hot" */
39
+ accessTier?: "Hot" | "Cool";
40
+ /** @default false */
41
+ allowBlobPublicAccess?: boolean;
42
+ /** @default "TLS1_2" */
43
+ minimumTlsVersion?: "TLS1_0" | "TLS1_1" | "TLS1_2";
44
+ tags?: Record<string, string>;
45
+ /** @default true */
46
+ delete?: boolean;
47
+ }
48
+
49
+ export type StorageAccount = Resource<
50
+ "Azure.StorageAccount",
51
+ StorageAccountProps,
52
+ {
53
+ name: string;
54
+ resourceGroupName: string;
55
+ location: string;
56
+ storageAccountId: string;
57
+ primaryBlobEndpoint?: string;
58
+ primaryQueueEndpoint?: string;
59
+ primaryFileEndpoint?: string;
60
+ primaryTableEndpoint?: string;
61
+ primaryAccessKey?: Redacted.Redacted<string>;
62
+ primaryConnectionString?: Redacted.Redacted<string>;
63
+ provisioningState?: string;
64
+ tags?: Record<string, string>;
65
+ },
66
+ never,
67
+ Providers
68
+ >;
69
+
70
+ /**
71
+ * Azure Storage Account — a namespace for Blob, Queue, File, and Table storage.
72
+ *
73
+ * @example Storage account in a resource group
74
+ * ```ts
75
+ * const storage = yield* Azure.StorageAccount("Storage", {
76
+ * resourceGroup: group,
77
+ * sku: "Standard_LRS",
78
+ * });
79
+ * ```
80
+ */
81
+ export const StorageAccount = Resource<StorageAccount>("Azure.StorageAccount");
82
+
83
+ export const StorageAccountProvider = () =>
84
+ Provider.effect(
85
+ StorageAccount,
86
+ Effect.gen(function* () {
87
+ const clients = yield* makeAzureClients;
88
+ const names = yield* makePhysicalNames;
89
+
90
+ const storageAccountName = (id: string, instanceId: string, name?: string) =>
91
+ names.physicalName(id, instanceId, name, {
92
+ maxLength: 24,
93
+ suffixLength: 8,
94
+ delimiter: "",
95
+ lowercase: true,
96
+ });
97
+
98
+ return StorageAccount.Provider.of({
99
+ stables: ["name", "storageAccountId", "resourceGroupName"],
100
+ list: () =>
101
+ Effect.gen(function* () {
102
+ const groups = yield* Effect.tryPromise({
103
+ try: () => collectAzurePages(clients.resources.resourceGroups.list()),
104
+ catch: (cause) => azureError({ operation: "list resource groups", cause }),
105
+ });
106
+ const accounts = yield* Effect.forEach(
107
+ groups,
108
+ (group) => {
109
+ if (!group.name) return Effect.succeed([]);
110
+ return Effect.tryPromise({
111
+ try: () => collectAzurePages(
112
+ clients.storage.storageAccounts.listByResourceGroup(group.name!),
113
+ ),
114
+ catch: (cause) =>
115
+ azureError({ operation: "list storage accounts", resource: group.name, cause }),
116
+ }).pipe(Effect.map((items) => items.map((account) => [group.name!, account] as const)));
117
+ },
118
+ { concurrency: 4 },
119
+ );
120
+ return yield* Effect.forEach(
121
+ accounts.flat(),
122
+ ([resourceGroupName, account]) =>
123
+ account.tags?.["alchemy:logical-id"]
124
+ ? toAttributes(account, resourceGroupName)
125
+ : Effect.succeed(undefined),
126
+ { concurrency: 4 },
127
+ ).pipe(Effect.map((items) => items.filter((item) => item !== undefined)));
128
+ }),
129
+ diff: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
130
+ if (!isResolved(news)) return undefined;
131
+ if (!output) return undefined;
132
+ const name = storageAccountName(id, instanceId, news.name);
133
+ const oldName = output.name;
134
+ const resourceGroupName = yield* getResourceGroupName(news.resourceGroup);
135
+ const location = news.location ?? (yield* getResourceGroupLocation(news.resourceGroup));
136
+ if (
137
+ name !== oldName ||
138
+ resourceGroupName !== output.resourceGroupName ||
139
+ (location !== undefined && location !== output.location)
140
+ ) {
141
+ return { action: "replace" } as const;
142
+ }
143
+ if (
144
+ (olds.kind ?? "StorageV2") !== (news.kind ?? "StorageV2") ||
145
+ storageSkuRequiresReplacement(olds.sku ?? "Standard_LRS", news.sku ?? "Standard_LRS")
146
+ ) {
147
+ return { action: "replace" } as const;
148
+ }
149
+ if (!diffValueEqual({
150
+ accessTier: olds.accessTier ?? "Hot",
151
+ allowBlobPublicAccess: olds.allowBlobPublicAccess ?? false,
152
+ minimumTlsVersion: olds.minimumTlsVersion ?? "TLS1_2",
153
+ tags: olds.tags ?? {},
154
+ }, {
155
+ accessTier: news.accessTier ?? "Hot",
156
+ allowBlobPublicAccess: news.allowBlobPublicAccess ?? false,
157
+ minimumTlsVersion: news.minimumTlsVersion ?? "TLS1_2",
158
+ tags: news.tags ?? {},
159
+ })) {
160
+ return { action: "update" } as const;
161
+ }
162
+ return undefined;
163
+ }),
164
+ read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) {
165
+ const resourceGroupName =
166
+ output?.resourceGroupName ??
167
+ (olds ? yield* getResourceGroupName(olds.resourceGroup) : undefined);
168
+ if (!resourceGroupName) return undefined;
169
+ const name = output?.name ?? storageAccountName(id, instanceId, olds?.name);
170
+ const account = yield* Effect.tryPromise({
171
+ try: () => clients.storage.storageAccounts.getProperties(resourceGroupName, name),
172
+ catch: (cause) => azureError({ operation: "read storage account", resource: name, cause }),
173
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
174
+ if (!account) return undefined;
175
+ const attrs = yield* toAttributes(account, resourceGroupName);
176
+ return hasAlchemyTags(id, account.tags) ? attrs : Unowned(attrs);
177
+ }),
178
+ reconcile: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
179
+ const name = storageAccountName(id, instanceId, news.name);
180
+ validateStorageAccountName(name);
181
+ const resourceGroupName = yield* getResourceGroupName(news.resourceGroup);
182
+ const location = news.location ?? (yield* getResourceGroupLocation(news.resourceGroup));
183
+ if (!location) {
184
+ throw new Error(
185
+ `StorageAccount "${id}" requires location when resourceGroup is a string.`,
186
+ );
187
+ }
188
+ if (output && olds) {
189
+ const existing = yield* Effect.tryPromise({
190
+ try: () => clients.storage.storageAccounts.getProperties(resourceGroupName, name),
191
+ catch: (cause) =>
192
+ azureError({ operation: "read storage account before update", resource: name, cause }),
193
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
194
+ if (existing && hasAlchemyTags(id, output.tags) && !hasAlchemyTags(id, existing.tags)) {
195
+ throw new Error(`Cannot adopt resource "${name}" without --adopt.`);
196
+ }
197
+ }
198
+ const account = yield* Effect.tryPromise({
199
+ try: async () => {
200
+ const poller = await clients.storage.storageAccounts.beginCreate(
201
+ resourceGroupName,
202
+ name,
203
+ {
204
+ location,
205
+ sku: { name: news.sku ?? "Standard_LRS" },
206
+ kind: news.kind ?? "StorageV2",
207
+ accessTier: news.accessTier ?? "Hot",
208
+ allowBlobPublicAccess: news.allowBlobPublicAccess ?? false,
209
+ minimumTlsVersion: news.minimumTlsVersion ?? "TLS1_2",
210
+ tags: withAlchemyTags(id, news.tags),
211
+ },
212
+ );
213
+ return await poller.pollUntilDone();
214
+ },
215
+ catch: (cause) =>
216
+ azureError({ operation: "reconcile storage account", resource: name, cause }),
217
+ });
218
+ return yield* toAttributes(account, resourceGroupName);
219
+ }),
220
+ delete: Effect.fnUntraced(function* ({ olds, output, session }) {
221
+ if (olds.delete === false) return;
222
+ yield* session.note(`Deleting Azure storage account: ${output.name}`);
223
+ yield* Effect.tryPromise({
224
+ try: () => clients.storage.storageAccounts.delete(output.resourceGroupName, output.name),
225
+ catch: (cause) => azureError({ operation: "delete storage account", resource: output.name, cause }),
226
+ }).pipe(Effect.catchIf(isNotFound, () => Effect.void));
227
+ }),
228
+ });
229
+
230
+ function toAttributes(account: AzureStorageAccount, resourceGroupName: string) {
231
+ return Effect.gen(function* () {
232
+ if (!account.name || !account.id || !account.location) {
233
+ throw new Error("Azure returned an incomplete storage account response");
234
+ }
235
+ const keys = yield* Effect.tryPromise({
236
+ try: () => clients.storage.storageAccounts.listKeys(resourceGroupName, account.name!),
237
+ catch: (cause) => azureError({ operation: "list storage account keys", resource: account.name, cause }),
238
+ }).pipe(Effect.catch(() => Effect.succeed(undefined)));
239
+ const primaryAccessKey = keys?.keys?.[0]?.value;
240
+ return {
241
+ name: account.name,
242
+ resourceGroupName,
243
+ location: account.location,
244
+ storageAccountId: account.id,
245
+ primaryBlobEndpoint: account.primaryEndpoints?.blob,
246
+ primaryQueueEndpoint: account.primaryEndpoints?.queue,
247
+ primaryFileEndpoint: account.primaryEndpoints?.file,
248
+ primaryTableEndpoint: account.primaryEndpoints?.table,
249
+ primaryAccessKey: primaryAccessKey ? Redacted.make(primaryAccessKey) : undefined,
250
+ primaryConnectionString: primaryAccessKey
251
+ ? Redacted.make(
252
+ `DefaultEndpointsProtocol=https;AccountName=${account.name};AccountKey=${primaryAccessKey};EndpointSuffix=core.windows.net`,
253
+ )
254
+ : undefined,
255
+ provisioningState: account.provisioningState,
256
+ tags: account.tags,
257
+ } satisfies StorageAccount["Attributes"];
258
+ });
259
+ }
260
+ }),
261
+ );
262
+
263
+ export function getResourceGroupName(resourceGroup: string | ResourceGroup) {
264
+ return Effect.gen(function* () {
265
+ if (typeof resourceGroup === "string") return resourceGroup;
266
+ return yield* resolveResourceValue(resourceGroup.name);
267
+ });
268
+ }
269
+
270
+ function storageSkuRequiresReplacement(oldSku: string, newSku: string) {
271
+ if (oldSku === newSku) return false;
272
+ const tier = (sku: string) => sku.split("_")[0];
273
+ const replication = (sku: string) => sku.split("_").at(-1) ?? sku;
274
+ const replicationFamily = (sku: string) =>
275
+ ["ZRS", "GZRS", "RAGZRS"].includes(replication(sku)) ? "zonal" : "regional";
276
+ return tier(oldSku) !== tier(newSku) || replicationFamily(oldSku) !== replicationFamily(newSku);
277
+ }
278
+
279
+ function getResourceGroupLocation(resourceGroup: string | ResourceGroup) {
280
+ return Effect.gen(function* () {
281
+ if (typeof resourceGroup === "string") return undefined;
282
+ return yield* resolveResourceValue(resourceGroup.location);
283
+ });
284
+ }
285
+
286
+ function validateStorageAccountName(name: string) {
287
+ if (!/^[a-z0-9]{3,24}$/.test(name)) {
288
+ throw new Error(
289
+ `Azure storage account name "${name}" is invalid. It must be 3-24 characters and contain only lowercase letters and numbers.`,
290
+ );
291
+ }
292
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./AuthProvider.ts";
2
+ export * from "./BlobContainer.ts";
3
+ export * from "./BlobState.ts";
4
+ export * from "./Clients.ts";
5
+ export * from "./ContainerApp.ts";
6
+ export * from "./ContainerAppEnvironment.ts";
7
+ export * from "./ContainerImage.ts";
8
+ export * from "./ContainerRegistry.ts";
9
+ export * from "./Credentials.ts";
10
+ export * from "./Errors.ts";
11
+ export * from "./MoreResources.ts";
12
+ export * from "./Providers.ts";
13
+ export * from "./ResourceProviderRegistration.ts";
14
+ export * from "./ResourceGroup.ts";
15
+ export * from "./StorageAccount.ts";