@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.
- package/ARCHITECTURE.md +170 -0
- package/CHANGELOG.md +71 -0
- package/LICENSE +201 -0
- package/README.md +241 -0
- package/env.example +13 -0
- package/package.json +87 -0
- package/src/AuthProvider.ts +332 -0
- package/src/BlobContainer.ts +268 -0
- package/src/BlobState.ts +289 -0
- package/src/Clients.ts +122 -0
- package/src/ContainerApp.ts +555 -0
- package/src/ContainerAppEnvironment.ts +253 -0
- package/src/ContainerImage.ts +181 -0
- package/src/ContainerRegistry.ts +232 -0
- package/src/Credentials.ts +69 -0
- package/src/Errors.ts +101 -0
- package/src/Internal.ts +184 -0
- package/src/MoreResources.ts +2189 -0
- package/src/Providers.ts +125 -0
- package/src/ResourceGroup.ts +171 -0
- package/src/ResourceProviderRegistration.ts +177 -0
- package/src/StorageAccount.ts +292 -0
- package/src/index.ts +15 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { BlobContainer as AzureBlobContainer } from "@azure/arm-storage";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { isResolved } from "alchemy/Diff";
|
|
4
|
+
import { Resource } from "alchemy";
|
|
5
|
+
import * as Provider from "alchemy/Provider";
|
|
6
|
+
import { Unowned } from "alchemy/AdoptPolicy";
|
|
7
|
+
import { makeAzureClients } from "./Clients.ts";
|
|
8
|
+
import { azureError, isNotFound } from "./Errors.ts";
|
|
9
|
+
import { collectAzurePages, diffValueEqual, makePhysicalNames, resolveResourceValue } from "./Internal.ts";
|
|
10
|
+
import type { Providers } from "./Providers.ts";
|
|
11
|
+
import type { StorageAccount } from "./StorageAccount.ts";
|
|
12
|
+
import { getResourceGroupName } from "./StorageAccount.ts";
|
|
13
|
+
|
|
14
|
+
const ALCHEMY_METADATA_ID = "alchemyLogicalId";
|
|
15
|
+
|
|
16
|
+
export interface BlobContainerProps {
|
|
17
|
+
/**
|
|
18
|
+
* Container name. Defaults to a deterministic physical name.
|
|
19
|
+
*/
|
|
20
|
+
name?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Storage account object or name.
|
|
23
|
+
*/
|
|
24
|
+
storageAccount: string | StorageAccount;
|
|
25
|
+
/**
|
|
26
|
+
* Resource group name. Required when `storageAccount` is a string.
|
|
27
|
+
*/
|
|
28
|
+
resourceGroup?: string;
|
|
29
|
+
/** @default "None" */
|
|
30
|
+
publicAccess?: "None" | "Blob" | "Container";
|
|
31
|
+
metadata?: Record<string, string>;
|
|
32
|
+
/** @default true */
|
|
33
|
+
delete?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type BlobContainer = Resource<
|
|
37
|
+
"Azure.BlobContainer",
|
|
38
|
+
BlobContainerProps,
|
|
39
|
+
{
|
|
40
|
+
name: string;
|
|
41
|
+
storageAccountName: string;
|
|
42
|
+
resourceGroupName: string;
|
|
43
|
+
url: string;
|
|
44
|
+
publicAccess?: string;
|
|
45
|
+
metadata?: Record<string, string>;
|
|
46
|
+
},
|
|
47
|
+
never,
|
|
48
|
+
Providers
|
|
49
|
+
>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Azure Blob Container — object storage inside an Azure Storage Account.
|
|
53
|
+
*
|
|
54
|
+
* @example Private blob container
|
|
55
|
+
* ```ts
|
|
56
|
+
* const uploads = yield* Azure.BlobContainer("Uploads", {
|
|
57
|
+
* storageAccount: storage,
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export const BlobContainer = Resource<BlobContainer>("Azure.BlobContainer");
|
|
62
|
+
|
|
63
|
+
export const BlobContainerProvider = () =>
|
|
64
|
+
Provider.effect(
|
|
65
|
+
BlobContainer,
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const clients = yield* makeAzureClients;
|
|
68
|
+
const names = yield* makePhysicalNames;
|
|
69
|
+
|
|
70
|
+
const containerName = (id: string, instanceId: string, name?: string) =>
|
|
71
|
+
names.physicalName(id, instanceId, name, {
|
|
72
|
+
maxLength: 63,
|
|
73
|
+
suffixLength: 8,
|
|
74
|
+
lowercase: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return BlobContainer.Provider.of({
|
|
78
|
+
stables: ["name", "storageAccountName", "resourceGroupName", "url"],
|
|
79
|
+
list: () =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
const groups = yield* Effect.tryPromise({
|
|
82
|
+
try: () => collectAzurePages(clients.resources.resourceGroups.list()),
|
|
83
|
+
catch: (cause) => azureError({ operation: "list resource groups", cause }),
|
|
84
|
+
});
|
|
85
|
+
const accounts = yield* Effect.forEach(
|
|
86
|
+
groups,
|
|
87
|
+
(group) => {
|
|
88
|
+
if (!group.name) return Effect.succeed([]);
|
|
89
|
+
return Effect.tryPromise({
|
|
90
|
+
try: () => collectAzurePages(
|
|
91
|
+
clients.storage.storageAccounts.listByResourceGroup(group.name!),
|
|
92
|
+
),
|
|
93
|
+
catch: (cause) =>
|
|
94
|
+
azureError({ operation: "list storage accounts", resource: group.name, cause }),
|
|
95
|
+
}).pipe(Effect.map((items) => items.map((account) => [group.name!, account] as const)));
|
|
96
|
+
},
|
|
97
|
+
{ concurrency: 4 },
|
|
98
|
+
);
|
|
99
|
+
const containers = yield* Effect.forEach(
|
|
100
|
+
accounts.flat(),
|
|
101
|
+
([resourceGroupName, account]) => {
|
|
102
|
+
if (!account.name) return Effect.succeed([]);
|
|
103
|
+
return Effect.tryPromise({
|
|
104
|
+
try: () => collectAzurePages(
|
|
105
|
+
clients.storage.blobContainers.list(resourceGroupName, account.name!),
|
|
106
|
+
),
|
|
107
|
+
catch: (cause) =>
|
|
108
|
+
azureError({ operation: "list blob containers", resource: account.name, cause }),
|
|
109
|
+
}).pipe(
|
|
110
|
+
Effect.map((items) =>
|
|
111
|
+
items.map((container) => [resourceGroupName, account.name!, container] as const),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
{ concurrency: 4 },
|
|
116
|
+
);
|
|
117
|
+
return containers
|
|
118
|
+
.flat()
|
|
119
|
+
.filter(([, , container]) => container.metadata?.[ALCHEMY_METADATA_ID])
|
|
120
|
+
.map(([resourceGroupName, storageAccountName, container]) =>
|
|
121
|
+
toAttributes(container, resourceGroupName, storageAccountName, container.name!),
|
|
122
|
+
);
|
|
123
|
+
}),
|
|
124
|
+
diff: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
|
|
125
|
+
if (!isResolved(news)) return undefined;
|
|
126
|
+
if (!output) return undefined;
|
|
127
|
+
const name = containerName(id, instanceId, news.name);
|
|
128
|
+
const oldName = output.name;
|
|
129
|
+
const storageAccountName = yield* getStorageAccountName(news.storageAccount);
|
|
130
|
+
const resourceGroupName = yield* getContainerResourceGroup(news);
|
|
131
|
+
if (
|
|
132
|
+
name !== oldName ||
|
|
133
|
+
storageAccountName !== output.storageAccountName ||
|
|
134
|
+
resourceGroupName !== output.resourceGroupName
|
|
135
|
+
) {
|
|
136
|
+
return { action: "replace" } as const;
|
|
137
|
+
}
|
|
138
|
+
if (!diffValueEqual({
|
|
139
|
+
publicAccess: olds.publicAccess ?? "None",
|
|
140
|
+
metadata: olds.metadata ?? {},
|
|
141
|
+
}, {
|
|
142
|
+
publicAccess: news.publicAccess ?? "None",
|
|
143
|
+
metadata: news.metadata ?? {},
|
|
144
|
+
})) {
|
|
145
|
+
return { action: "update" } as const;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}),
|
|
149
|
+
read: Effect.fnUntraced(function* ({ id, instanceId, olds, output }) {
|
|
150
|
+
const name = output?.name ?? containerName(id, instanceId, olds?.name);
|
|
151
|
+
const storageAccountName =
|
|
152
|
+
output?.storageAccountName ??
|
|
153
|
+
(olds ? yield* getStorageAccountName(olds.storageAccount) : undefined);
|
|
154
|
+
const resourceGroupName =
|
|
155
|
+
output?.resourceGroupName ??
|
|
156
|
+
(olds ? yield* getContainerResourceGroup(olds) : undefined);
|
|
157
|
+
if (!storageAccountName || !resourceGroupName) return undefined;
|
|
158
|
+
const container = yield* Effect.tryPromise({
|
|
159
|
+
try: () => clients.storage.blobContainers.get(resourceGroupName, storageAccountName, name),
|
|
160
|
+
catch: (cause) => azureError({ operation: "read blob container", resource: name, cause }),
|
|
161
|
+
}).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
|
|
162
|
+
if (!container) return undefined;
|
|
163
|
+
const attrs = toAttributes(container, resourceGroupName, storageAccountName, name);
|
|
164
|
+
return container.metadata?.[ALCHEMY_METADATA_ID] === id
|
|
165
|
+
? attrs
|
|
166
|
+
: Unowned(attrs);
|
|
167
|
+
}),
|
|
168
|
+
reconcile: Effect.fnUntraced(function* ({ id, instanceId, olds, news, output }) {
|
|
169
|
+
const name = containerName(id, instanceId, news.name);
|
|
170
|
+
validateContainerName(name);
|
|
171
|
+
const storageAccountName = yield* getStorageAccountName(news.storageAccount);
|
|
172
|
+
const resourceGroupName = yield* getContainerResourceGroup(news);
|
|
173
|
+
if (!resourceGroupName) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`BlobContainer "${id}" requires resourceGroup when storageAccount is a string.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (output && olds) {
|
|
179
|
+
const existing = yield* Effect.tryPromise({
|
|
180
|
+
try: () => clients.storage.blobContainers.get(resourceGroupName, storageAccountName, name),
|
|
181
|
+
catch: (cause) =>
|
|
182
|
+
azureError({ operation: "read blob container before update", resource: name, cause }),
|
|
183
|
+
}).pipe(Effect.catchIf(isNotFound, () => Effect.succeed(undefined)));
|
|
184
|
+
if (
|
|
185
|
+
existing &&
|
|
186
|
+
output.metadata?.[ALCHEMY_METADATA_ID] === id &&
|
|
187
|
+
existing.metadata?.[ALCHEMY_METADATA_ID] !== id
|
|
188
|
+
) {
|
|
189
|
+
throw new Error(`Cannot adopt resource "${name}" without --adopt.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const params = {
|
|
193
|
+
publicAccess: news.publicAccess ?? "None",
|
|
194
|
+
metadata: {
|
|
195
|
+
...news.metadata,
|
|
196
|
+
[ALCHEMY_METADATA_ID]: id,
|
|
197
|
+
},
|
|
198
|
+
} as Parameters<typeof clients.storage.blobContainers.create>[3];
|
|
199
|
+
// `blobContainers.create` is an idempotent PUT (create-or-update). Call it
|
|
200
|
+
// directly on the client so the SDK method keeps its `this` binding; aliasing
|
|
201
|
+
// it to a local variable detaches `this` and breaks `this.client` at runtime.
|
|
202
|
+
const container = yield* Effect.tryPromise({
|
|
203
|
+
try: () =>
|
|
204
|
+
clients.storage.blobContainers.create(
|
|
205
|
+
resourceGroupName,
|
|
206
|
+
storageAccountName,
|
|
207
|
+
name,
|
|
208
|
+
params,
|
|
209
|
+
),
|
|
210
|
+
catch: (cause) =>
|
|
211
|
+
azureError({ operation: "reconcile blob container", resource: name, cause }),
|
|
212
|
+
});
|
|
213
|
+
return toAttributes(container, resourceGroupName, storageAccountName, name);
|
|
214
|
+
}),
|
|
215
|
+
delete: Effect.fnUntraced(function* ({ olds, output, session }) {
|
|
216
|
+
if (olds.delete === false) return;
|
|
217
|
+
yield* session.note(`Deleting Azure blob container: ${output.name}`);
|
|
218
|
+
yield* Effect.tryPromise({
|
|
219
|
+
try: () => clients.storage.blobContainers.delete(
|
|
220
|
+
output.resourceGroupName,
|
|
221
|
+
output.storageAccountName,
|
|
222
|
+
output.name,
|
|
223
|
+
),
|
|
224
|
+
catch: (cause) => azureError({ operation: "delete blob container", resource: output.name, cause }),
|
|
225
|
+
}).pipe(Effect.catchIf(isNotFound, () => Effect.void));
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
function getStorageAccountName(storageAccount: string | StorageAccount) {
|
|
232
|
+
return Effect.gen(function* () {
|
|
233
|
+
if (typeof storageAccount === "string") return storageAccount;
|
|
234
|
+
return yield* resolveResourceValue(storageAccount.name);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getContainerResourceGroup(props: BlobContainerProps) {
|
|
239
|
+
return Effect.gen(function* () {
|
|
240
|
+
if (props.resourceGroup) return props.resourceGroup;
|
|
241
|
+
if (typeof props.storageAccount === "string") return undefined;
|
|
242
|
+
return yield* resolveResourceValue(props.storageAccount.resourceGroupName);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function toAttributes(
|
|
247
|
+
container: AzureBlobContainer,
|
|
248
|
+
resourceGroupName: string,
|
|
249
|
+
storageAccountName: string,
|
|
250
|
+
name: string,
|
|
251
|
+
) {
|
|
252
|
+
return {
|
|
253
|
+
name,
|
|
254
|
+
storageAccountName,
|
|
255
|
+
resourceGroupName,
|
|
256
|
+
url: `https://${storageAccountName}.blob.core.windows.net/${name}`,
|
|
257
|
+
publicAccess: container.publicAccess,
|
|
258
|
+
metadata: container.metadata,
|
|
259
|
+
} satisfies BlobContainer["Attributes"];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateContainerName(name: string) {
|
|
263
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])$/.test(name) || name.includes("--")) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Azure blob container name "${name}" is invalid. It must be 3-63 characters, lowercase letters, numbers, and hyphens, without leading, trailing, or consecutive hyphens.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/BlobState.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { BlobServiceClient, StorageSharedKeyCredential } from "@azure/storage-blob";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Layer from "effect/Layer";
|
|
4
|
+
import { decodeFqn, encodeFqn } from "alchemy/FQN";
|
|
5
|
+
import {
|
|
6
|
+
encodeState,
|
|
7
|
+
reviveState,
|
|
8
|
+
State,
|
|
9
|
+
STATE_STORE_VERSION,
|
|
10
|
+
StateStoreError,
|
|
11
|
+
type ReplacedResourceState,
|
|
12
|
+
type ResourceState,
|
|
13
|
+
type StateService,
|
|
14
|
+
} from "alchemy/State";
|
|
15
|
+
|
|
16
|
+
export interface BlobStateProps {
|
|
17
|
+
/**
|
|
18
|
+
* Azure Storage account name.
|
|
19
|
+
*
|
|
20
|
+
* @default process.env.AZURE_STORAGE_ACCOUNT
|
|
21
|
+
*/
|
|
22
|
+
accountName?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Azure Storage account key.
|
|
25
|
+
*
|
|
26
|
+
* @default process.env.AZURE_STORAGE_KEY
|
|
27
|
+
*/
|
|
28
|
+
accountKey?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Blob container containing state objects.
|
|
31
|
+
*
|
|
32
|
+
* The container must already exist.
|
|
33
|
+
*
|
|
34
|
+
* @default "alchemy-state"
|
|
35
|
+
*/
|
|
36
|
+
containerName?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Blob key prefix. Use this to share one container across multiple stores.
|
|
39
|
+
*
|
|
40
|
+
* @default "alchemy/state"
|
|
41
|
+
*/
|
|
42
|
+
prefix?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BlobStateContainer {
|
|
46
|
+
getBlobClient(name: string): {
|
|
47
|
+
delete(): Promise<unknown>;
|
|
48
|
+
download(): Promise<{ readableStreamBody?: NodeJS.ReadableStream }>;
|
|
49
|
+
};
|
|
50
|
+
getBlockBlobClient(name: string): {
|
|
51
|
+
upload(body: string, contentLength: number, options?: unknown): Promise<unknown>;
|
|
52
|
+
};
|
|
53
|
+
listBlobsFlat(options: { prefix: string }): AsyncIterable<{ name: string }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Azure Blob Storage-backed Alchemy v2 state store.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* export default Alchemy.Stack(
|
|
62
|
+
* "MyApp",
|
|
63
|
+
* {
|
|
64
|
+
* providers: Azure.providers(),
|
|
65
|
+
* state: Azure.blobState({
|
|
66
|
+
* accountName: "mystorageaccount",
|
|
67
|
+
* accountKey: process.env.AZURE_STORAGE_KEY!,
|
|
68
|
+
* containerName: "alchemy-state",
|
|
69
|
+
* }),
|
|
70
|
+
* },
|
|
71
|
+
* Effect.gen(function* () {
|
|
72
|
+
* // resources...
|
|
73
|
+
* }),
|
|
74
|
+
* );
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const blobState = (props: BlobStateProps = {}) =>
|
|
78
|
+
Layer.effect(State, Effect.cached(makeBlobState(props)));
|
|
79
|
+
|
|
80
|
+
export const makeBlobState = (props: BlobStateProps = {}) =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const accountName = props.accountName ?? process.env.AZURE_STORAGE_ACCOUNT;
|
|
83
|
+
const accountKey = props.accountKey ?? process.env.AZURE_STORAGE_KEY;
|
|
84
|
+
const containerName = props.containerName ?? "alchemy-state";
|
|
85
|
+
const prefix = normalizePrefix(props.prefix ?? "alchemy/state");
|
|
86
|
+
|
|
87
|
+
if (!accountName) {
|
|
88
|
+
return missingBlobStateService("Azure Blob state requires accountName or AZURE_STORAGE_ACCOUNT.");
|
|
89
|
+
}
|
|
90
|
+
if (!accountKey) {
|
|
91
|
+
return missingBlobStateService("Azure Blob state requires accountKey or AZURE_STORAGE_KEY.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const credential = new StorageSharedKeyCredential(accountName, accountKey);
|
|
95
|
+
const blob = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, credential);
|
|
96
|
+
const container = blob.getContainerClient(containerName);
|
|
97
|
+
|
|
98
|
+
return yield* makeBlobStateService(container, prefix);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function missingBlobStateService(message: string): StateService {
|
|
102
|
+
const fail = () => Effect.fail(new StateStoreError({ message }));
|
|
103
|
+
return {
|
|
104
|
+
id: "azure-blob",
|
|
105
|
+
getVersion: fail,
|
|
106
|
+
listStacks: fail,
|
|
107
|
+
listStages: fail,
|
|
108
|
+
list: fail,
|
|
109
|
+
get: fail,
|
|
110
|
+
getReplacedResources: fail,
|
|
111
|
+
set: fail,
|
|
112
|
+
delete: fail,
|
|
113
|
+
deleteStack: fail,
|
|
114
|
+
getOutput: fail,
|
|
115
|
+
setOutput: fail,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const makeBlobStateService = (container: BlobStateContainer, prefix: string) =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
const normalizedPrefix = normalizePrefix(prefix);
|
|
122
|
+
|
|
123
|
+
const run = <A>(thunk: () => Promise<A>) =>
|
|
124
|
+
Effect.tryPromise({
|
|
125
|
+
try: thunk,
|
|
126
|
+
catch: (cause) =>
|
|
127
|
+
new StateStoreError({
|
|
128
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
129
|
+
cause: cause instanceof Error ? cause : undefined,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const ignoreMissing = <A>(effect: Effect.Effect<A, StateStoreError>) =>
|
|
134
|
+
effect.pipe(
|
|
135
|
+
Effect.catchIf(
|
|
136
|
+
(error) => isMissing(error.cause) || error.message.includes("404"),
|
|
137
|
+
() => Effect.void,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const stackPrefix = (stack: string) => `${normalizedPrefix}${encodeSegment(stack)}/`;
|
|
142
|
+
const stagePrefix = (stack: string, stage: string) =>
|
|
143
|
+
`${stackPrefix(stack)}${encodeSegment(stage)}/`;
|
|
144
|
+
const resourceBlob = (request: { stack: string; stage: string; fqn: string }) =>
|
|
145
|
+
`${stagePrefix(request.stack, request.stage)}${encodeFqn(request.fqn)}.json`;
|
|
146
|
+
const outputBlob = (request: { stack: string; stage: string }) =>
|
|
147
|
+
`${stagePrefix(request.stack, request.stage)}__stack_output__.json`;
|
|
148
|
+
|
|
149
|
+
const listBlobNames = (listPrefix: string) =>
|
|
150
|
+
run(async () => {
|
|
151
|
+
const names: string[] = [];
|
|
152
|
+
for await (const item of container.listBlobsFlat({ prefix: listPrefix })) {
|
|
153
|
+
names.push(item.name);
|
|
154
|
+
}
|
|
155
|
+
return names;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const listChildSegments = (listPrefix: string) =>
|
|
159
|
+
listBlobNames(listPrefix).pipe(
|
|
160
|
+
Effect.map((names) =>
|
|
161
|
+
Array.from(
|
|
162
|
+
new Set(
|
|
163
|
+
names.flatMap((name) => {
|
|
164
|
+
const rest = name.slice(listPrefix.length);
|
|
165
|
+
const segment = rest.split("/")[0];
|
|
166
|
+
return segment ? [decodeSegment(segment)] : [];
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
).sort(),
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const service: StateService = {
|
|
174
|
+
id: "azure-blob",
|
|
175
|
+
getVersion: () => Effect.succeed(STATE_STORE_VERSION),
|
|
176
|
+
listStacks: () => listChildSegments(normalizedPrefix),
|
|
177
|
+
listStages: (stack) => listChildSegments(stackPrefix(stack)),
|
|
178
|
+
list: (request) =>
|
|
179
|
+
listBlobNames(stagePrefix(request.stack, request.stage)).pipe(
|
|
180
|
+
Effect.map((names) =>
|
|
181
|
+
names
|
|
182
|
+
.filter((name) => name.endsWith(".json") && !name.endsWith("/__stack_output__.json"))
|
|
183
|
+
.map((name) =>
|
|
184
|
+
decodeFqn(
|
|
185
|
+
name
|
|
186
|
+
.slice(stagePrefix(request.stack, request.stage).length)
|
|
187
|
+
.replace(/\.json$/, ""),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
.sort(),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
get: (request) =>
|
|
194
|
+
run(async () => {
|
|
195
|
+
const client = container.getBlobClient(resourceBlob(request));
|
|
196
|
+
const response = await client.download();
|
|
197
|
+
const text = await streamToString(response.readableStreamBody);
|
|
198
|
+
return JSON.parse(text, reviveState) as ResourceState;
|
|
199
|
+
}).pipe(
|
|
200
|
+
Effect.catchIf(
|
|
201
|
+
(error) => isMissing(error.cause) || error.message.includes("404"),
|
|
202
|
+
() => Effect.succeed(undefined),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
getReplacedResources: Effect.fnUntraced(function* (request) {
|
|
206
|
+
return (yield* Effect.all(
|
|
207
|
+
(yield* service.list(request)).map((fqn) => service.get({ ...request, fqn })),
|
|
208
|
+
{ concurrency: "unbounded" },
|
|
209
|
+
)).filter((state): state is ReplacedResourceState => state?.status === "replaced");
|
|
210
|
+
}),
|
|
211
|
+
set: (request) =>
|
|
212
|
+
run(async () => {
|
|
213
|
+
const body = JSON.stringify(encodeState(request.value), null, 2);
|
|
214
|
+
await container
|
|
215
|
+
.getBlockBlobClient(resourceBlob(request))
|
|
216
|
+
.upload(body, Buffer.byteLength(body), {
|
|
217
|
+
blobHTTPHeaders: { blobContentType: "application/json" },
|
|
218
|
+
});
|
|
219
|
+
return request.value;
|
|
220
|
+
}),
|
|
221
|
+
delete: (request) =>
|
|
222
|
+
ignoreMissing(run(() => container.getBlobClient(resourceBlob(request)).delete())),
|
|
223
|
+
getOutput: (request) =>
|
|
224
|
+
run(async () => {
|
|
225
|
+
const client = container.getBlobClient(outputBlob(request));
|
|
226
|
+
const response = await client.download();
|
|
227
|
+
const text = await streamToString(response.readableStreamBody);
|
|
228
|
+
return JSON.parse(text, reviveState) as unknown;
|
|
229
|
+
}).pipe(
|
|
230
|
+
Effect.catchIf(
|
|
231
|
+
(error) => isMissing(error.cause) || error.message.includes("404"),
|
|
232
|
+
() => Effect.succeed(undefined),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
setOutput: (request) =>
|
|
236
|
+
run(async () => {
|
|
237
|
+
const body = JSON.stringify(encodeState(request.value as any), null, 2);
|
|
238
|
+
await container
|
|
239
|
+
.getBlockBlobClient(outputBlob(request))
|
|
240
|
+
.upload(body, Buffer.byteLength(body), {
|
|
241
|
+
blobHTTPHeaders: { blobContentType: "application/json" },
|
|
242
|
+
});
|
|
243
|
+
return request.value;
|
|
244
|
+
}),
|
|
245
|
+
deleteStack: ({ stack, stage }) =>
|
|
246
|
+
listBlobNames(stage ? stagePrefix(stack, stage) : stackPrefix(stack)).pipe(
|
|
247
|
+
Effect.flatMap((names) =>
|
|
248
|
+
Effect.all(
|
|
249
|
+
names.map((name) => ignoreMissing(run(() => container.getBlobClient(name).delete()))),
|
|
250
|
+
{ concurrency: "unbounded" },
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
Effect.asVoid,
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return service;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function normalizePrefix(prefix: string) {
|
|
261
|
+
const trimmed = prefix.replace(/^\/+|\/+$/g, "");
|
|
262
|
+
return trimmed.length === 0 ? "" : `${trimmed}/`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function encodeSegment(value: string) {
|
|
266
|
+
return encodeURIComponent(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function decodeSegment(value: string) {
|
|
270
|
+
return decodeURIComponent(value);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isMissing(error: unknown) {
|
|
274
|
+
return (
|
|
275
|
+
!!error &&
|
|
276
|
+
typeof error === "object" &&
|
|
277
|
+
(("statusCode" in error && error.statusCode === 404) ||
|
|
278
|
+
("code" in error && error.code === "BlobNotFound"))
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function streamToString(stream: NodeJS.ReadableStream | undefined) {
|
|
283
|
+
if (!stream) return "";
|
|
284
|
+
const chunks: Buffer[] = [];
|
|
285
|
+
for await (const chunk of stream) {
|
|
286
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
287
|
+
}
|
|
288
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
289
|
+
}
|
package/src/Clients.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { WebSiteManagementClient } from "@azure/arm-appservice";
|
|
2
|
+
import type { TokenCredential } from "@azure/identity";
|
|
3
|
+
import { ContainerAppsAPIClient } from "@azure/arm-appcontainers";
|
|
4
|
+
import { ContainerRegistryManagementClient } from "@azure/arm-containerregistry";
|
|
5
|
+
import { CognitiveServicesManagementClient } from "@azure/arm-cognitiveservices";
|
|
6
|
+
import { ComputeManagementClient } from "@azure/arm-compute";
|
|
7
|
+
import { ContainerInstanceManagementClient } from "@azure/arm-containerinstance";
|
|
8
|
+
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
|
|
9
|
+
import { KeyVaultManagementClient } from "@azure/arm-keyvault";
|
|
10
|
+
import { ManagedServiceIdentityClient } from "@azure/arm-msi";
|
|
11
|
+
import { NetworkManagementClient } from "@azure/arm-network";
|
|
12
|
+
import { ResourceManagementClient } from "@azure/arm-resources";
|
|
13
|
+
import { ServiceBusManagementClient } from "@azure/arm-servicebus";
|
|
14
|
+
import { SqlManagementClient } from "@azure/arm-sql";
|
|
15
|
+
import { StorageManagementClient } from "@azure/arm-storage";
|
|
16
|
+
import * as Context from "effect/Context";
|
|
17
|
+
import * as Effect from "effect/Effect";
|
|
18
|
+
import * as Layer from "effect/Layer";
|
|
19
|
+
import type { ServiceClientCredentials } from "@azure/ms-rest-js";
|
|
20
|
+
import { AzureCredentials } from "./Credentials.ts";
|
|
21
|
+
|
|
22
|
+
export interface AzureClientsShape {
|
|
23
|
+
resources: ResourceManagementClient;
|
|
24
|
+
storage: StorageManagementClient;
|
|
25
|
+
msi: ManagedServiceIdentityClient;
|
|
26
|
+
appService: WebSiteManagementClient;
|
|
27
|
+
cosmosDB: CosmosDBManagementClient;
|
|
28
|
+
sql: SqlManagementClient;
|
|
29
|
+
network: NetworkManagementClient;
|
|
30
|
+
containerInstance: ContainerInstanceManagementClient;
|
|
31
|
+
compute: ComputeManagementClient;
|
|
32
|
+
keyVault: KeyVaultManagementClient;
|
|
33
|
+
serviceBus: ServiceBusManagementClient;
|
|
34
|
+
cognitiveServices: CognitiveServicesManagementClient;
|
|
35
|
+
appContainers: ContainerAppsAPIClient;
|
|
36
|
+
containerRegistry: ContainerRegistryManagementClient;
|
|
37
|
+
subscriptionId: string;
|
|
38
|
+
tenantId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Injectable Azure SDK client bundle. Provider lifecycle effects depend on this
|
|
43
|
+
* service rather than constructing clients directly, so tests can supply a
|
|
44
|
+
* fake implementation via {@link AzureClients} without touching the network.
|
|
45
|
+
*/
|
|
46
|
+
export class AzureClients extends Context.Service<AzureClients, AzureClientsShape>()(
|
|
47
|
+
"Azure.Clients",
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Yield the Azure client bundle from context. Kept as a named export so every
|
|
52
|
+
* provider reads clients the same way (`const clients = yield* makeAzureClients`).
|
|
53
|
+
*/
|
|
54
|
+
export const makeAzureClients = AzureClients;
|
|
55
|
+
|
|
56
|
+
/** Build the real Azure SDK clients from resolved {@link AzureCredentials}. */
|
|
57
|
+
export const buildAzureClients = (credentials: {
|
|
58
|
+
credential: TokenCredential;
|
|
59
|
+
subscriptionId: string;
|
|
60
|
+
tenantId?: string;
|
|
61
|
+
}): AzureClientsShape => {
|
|
62
|
+
const { credential, subscriptionId, tenantId } = credentials;
|
|
63
|
+
const serviceClientCredentials = toServiceClientCredentials(credential);
|
|
64
|
+
return {
|
|
65
|
+
resources: new ResourceManagementClient(credential, subscriptionId),
|
|
66
|
+
storage: new StorageManagementClient(credential, subscriptionId),
|
|
67
|
+
msi: new ManagedServiceIdentityClient(credential, subscriptionId),
|
|
68
|
+
appService: new WebSiteManagementClient(credential, subscriptionId),
|
|
69
|
+
cosmosDB: new CosmosDBManagementClient(serviceClientCredentials, subscriptionId),
|
|
70
|
+
sql: new SqlManagementClient(credential, subscriptionId),
|
|
71
|
+
network: new NetworkManagementClient(credential, subscriptionId),
|
|
72
|
+
containerInstance: new ContainerInstanceManagementClient(credential, subscriptionId),
|
|
73
|
+
compute: new ComputeManagementClient(credential, subscriptionId),
|
|
74
|
+
keyVault: new KeyVaultManagementClient(credential, subscriptionId),
|
|
75
|
+
serviceBus: new ServiceBusManagementClient(credential, subscriptionId),
|
|
76
|
+
cognitiveServices: new CognitiveServicesManagementClient(credential, subscriptionId),
|
|
77
|
+
appContainers: new ContainerAppsAPIClient(credential, subscriptionId),
|
|
78
|
+
containerRegistry: new ContainerRegistryManagementClient(credential, subscriptionId),
|
|
79
|
+
subscriptionId,
|
|
80
|
+
tenantId,
|
|
81
|
+
} satisfies AzureClientsShape;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Live layer that constructs Azure SDK clients from {@link AzureCredentials}. */
|
|
85
|
+
export const AzureClientsLive = Layer.effect(
|
|
86
|
+
AzureClients,
|
|
87
|
+
Effect.gen(function* () {
|
|
88
|
+
const credentials = yield* AzureCredentials;
|
|
89
|
+
const tenantId = credentials.tenantId ?? (yield* resolveTenantId(credentials.credential));
|
|
90
|
+
return buildAzureClients({ ...credentials, tenantId });
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const resolveTenantId = (credential: TokenCredential) =>
|
|
95
|
+
Effect.tryPromise(async () => {
|
|
96
|
+
const token = await credential.getToken("https://management.azure.com/.default");
|
|
97
|
+
if (!token) return undefined;
|
|
98
|
+
const [, payload] = token.token.split(".");
|
|
99
|
+
if (!payload) return undefined;
|
|
100
|
+
const decoded = JSON.parse(Buffer.from(base64UrlToBase64(payload), "base64").toString("utf8")) as {
|
|
101
|
+
tid?: string;
|
|
102
|
+
};
|
|
103
|
+
return decoded.tid;
|
|
104
|
+
}).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
105
|
+
|
|
106
|
+
function base64UrlToBase64(value: string) {
|
|
107
|
+
const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
108
|
+
return base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function toServiceClientCredentials(credential: TokenCredential): ServiceClientCredentials {
|
|
112
|
+
return {
|
|
113
|
+
signRequest: async (webResource) => {
|
|
114
|
+
const token = await credential.getToken("https://management.azure.com/.default");
|
|
115
|
+
if (!token) {
|
|
116
|
+
throw new Error("Failed to acquire Azure management token");
|
|
117
|
+
}
|
|
118
|
+
webResource.headers.set("Authorization", `Bearer ${token.token}`);
|
|
119
|
+
return webResource;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|