@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,69 @@
|
|
|
1
|
+
import { ClientSecretCredential, DefaultAzureCredential } from "@azure/identity";
|
|
2
|
+
import type { TokenCredential } from "@azure/identity";
|
|
3
|
+
import * as Config from "effect/Config";
|
|
4
|
+
import * as Context from "effect/Context";
|
|
5
|
+
import * as Effect from "effect/Effect";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
import * as Redacted from "effect/Redacted";
|
|
8
|
+
import { getAuthProvider } from "alchemy/Auth/AuthProvider";
|
|
9
|
+
import { ALCHEMY_PROFILE, AlchemyProfile } from "alchemy/Auth/Profile";
|
|
10
|
+
import {
|
|
11
|
+
AZURE_AUTH_PROVIDER_NAME,
|
|
12
|
+
type AzureAuthConfig,
|
|
13
|
+
type AzureResolvedCredentials,
|
|
14
|
+
} from "./AuthProvider.ts";
|
|
15
|
+
|
|
16
|
+
export interface AzureCredentialsService {
|
|
17
|
+
subscriptionId: string;
|
|
18
|
+
tenantId?: string;
|
|
19
|
+
clientId?: string;
|
|
20
|
+
clientSecret?: Redacted.Redacted<string>;
|
|
21
|
+
credential: TokenCredential;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AzureCredentials extends Context.Service<AzureCredentials, AzureCredentialsService>()(
|
|
25
|
+
"Azure.Credentials",
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
export const fromAuthProvider = () =>
|
|
29
|
+
Layer.effect(
|
|
30
|
+
AzureCredentials,
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const profile = yield* AlchemyProfile;
|
|
33
|
+
const auth = yield* getAuthProvider<AzureAuthConfig, AzureResolvedCredentials>(
|
|
34
|
+
AZURE_AUTH_PROVIDER_NAME,
|
|
35
|
+
);
|
|
36
|
+
const profileName = yield* ALCHEMY_PROFILE;
|
|
37
|
+
const ci = yield* Config.boolean("CI").pipe(Config.withDefault(false));
|
|
38
|
+
|
|
39
|
+
const resolved = yield* profile
|
|
40
|
+
.loadOrConfigure<AzureAuthConfig>(auth, profileName, { ci })
|
|
41
|
+
.pipe(Effect.flatMap((config) => auth.read(profileName, config)));
|
|
42
|
+
|
|
43
|
+
return createAzureCredentials(resolved);
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export function createAzureCredentials(
|
|
48
|
+
credentials: AzureResolvedCredentials,
|
|
49
|
+
): AzureCredentialsService {
|
|
50
|
+
if (credentials.method === "servicePrincipal") {
|
|
51
|
+
return {
|
|
52
|
+
subscriptionId: credentials.subscriptionId,
|
|
53
|
+
tenantId: credentials.tenantId,
|
|
54
|
+
clientId: credentials.clientId,
|
|
55
|
+
clientSecret: credentials.clientSecret,
|
|
56
|
+
credential: new ClientSecretCredential(
|
|
57
|
+
credentials.tenantId,
|
|
58
|
+
credentials.clientId,
|
|
59
|
+
Redacted.value(credentials.clientSecret),
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
subscriptionId: credentials.subscriptionId,
|
|
66
|
+
tenantId: credentials.tenantId,
|
|
67
|
+
credential: new DefaultAzureCredential(),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/Errors.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tagged error raised by Azure provider lifecycle methods when an Azure
|
|
5
|
+
* SDK call fails. Use `Effect.catchTag("AzureError", ...)` to handle
|
|
6
|
+
* these with full type-safety.
|
|
7
|
+
*/
|
|
8
|
+
export class AzureError extends Schema.TaggedErrorClass<AzureError>()(
|
|
9
|
+
"AzureError",
|
|
10
|
+
{
|
|
11
|
+
message: Schema.String,
|
|
12
|
+
/** Logical operation that triggered the failure, e.g. `reconcile resource group`. */
|
|
13
|
+
operation: Schema.optional(Schema.String),
|
|
14
|
+
/** Physical name of the Azure resource the operation targeted. */
|
|
15
|
+
resource: Schema.optional(Schema.String),
|
|
16
|
+
/** HTTP status code, when the underlying error carries one. */
|
|
17
|
+
statusCode: Schema.optional(Schema.Number),
|
|
18
|
+
/** Azure error code, e.g. `ResourceAlreadyExists`. */
|
|
19
|
+
code: Schema.optional(Schema.String),
|
|
20
|
+
cause: Schema.optional(Schema.Defect({ includeStack: true })),
|
|
21
|
+
},
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shape Azure SDK errors typically expose. Used to extract `statusCode`
|
|
26
|
+
* and `code` for {@link AzureError} construction.
|
|
27
|
+
*/
|
|
28
|
+
interface AzureSdkError extends Error {
|
|
29
|
+
statusCode?: number;
|
|
30
|
+
code?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hasSdkShape = (error: unknown): error is AzureSdkError =>
|
|
34
|
+
error instanceof Error &&
|
|
35
|
+
(("statusCode" in error && typeof (error as AzureSdkError).statusCode === "number") ||
|
|
36
|
+
("code" in error && typeof (error as AzureSdkError).code === "string"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Walk an error's `cause` chain to find the underlying Azure SDK error.
|
|
40
|
+
*
|
|
41
|
+
* `Effect.tryPromise(thunk)` (the single-argument form used across the
|
|
42
|
+
* providers) wraps a rejected promise in an `UnknownError`, stashing the real
|
|
43
|
+
* SDK error — including its `statusCode` / `code` — under `cause`. Detection
|
|
44
|
+
* helpers must therefore look past the top-level error, or idempotent `read` /
|
|
45
|
+
* `delete` paths never recognise a 404 / 409.
|
|
46
|
+
*/
|
|
47
|
+
function findSdkError(error: unknown): AzureSdkError | undefined {
|
|
48
|
+
const seen = new Set<unknown>();
|
|
49
|
+
let current: unknown = error;
|
|
50
|
+
while (current && !seen.has(current)) {
|
|
51
|
+
seen.add(current);
|
|
52
|
+
if (hasSdkShape(current)) return current;
|
|
53
|
+
current = (current as { cause?: unknown }).cause;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isAzureError(error: unknown): error is AzureSdkError {
|
|
59
|
+
return findSdkError(error) !== undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isNotFound(error: unknown): boolean {
|
|
63
|
+
if (error instanceof AzureError && error.statusCode === 404) return true;
|
|
64
|
+
return findSdkError(error)?.statusCode === 404;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isAlreadyExists(error: unknown): boolean {
|
|
68
|
+
const matches = (statusCode?: number, code?: string, message?: string) =>
|
|
69
|
+
statusCode === 409 || code === "ResourceAlreadyExists" || !!message?.includes("already exists");
|
|
70
|
+
if (error instanceof AzureError && matches(error.statusCode, error.code, error.message)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const sdk = findSdkError(error);
|
|
74
|
+
return matches(sdk?.statusCode, sdk?.code, sdk?.message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function errorMessage(error: unknown): string {
|
|
78
|
+
return error instanceof Error ? error.message : String(error);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wrap an arbitrary thrown value as an {@link AzureError}, preserving
|
|
83
|
+
* `statusCode` / `code` from Azure SDK errors. Suitable as the `catch:`
|
|
84
|
+
* mapper for `Effect.tryPromise`.
|
|
85
|
+
*/
|
|
86
|
+
export function azureError(input: {
|
|
87
|
+
operation: string;
|
|
88
|
+
resource?: string;
|
|
89
|
+
cause: unknown;
|
|
90
|
+
}): AzureError {
|
|
91
|
+
const { operation, resource, cause } = input;
|
|
92
|
+
const sdk = findSdkError(cause);
|
|
93
|
+
return new AzureError({
|
|
94
|
+
message: `Failed to ${operation}${resource ? ` "${resource}"` : ""}: ${errorMessage(cause)}`,
|
|
95
|
+
operation,
|
|
96
|
+
resource,
|
|
97
|
+
statusCode: sdk?.statusCode,
|
|
98
|
+
code: sdk?.code,
|
|
99
|
+
cause,
|
|
100
|
+
});
|
|
101
|
+
}
|
package/src/Internal.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Fiber from "effect/Fiber";
|
|
4
|
+
import * as Redacted from "effect/Redacted";
|
|
5
|
+
import { Stack, Stage } from "alchemy";
|
|
6
|
+
import type { ResourceGroup } from "./ResourceGroup.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Emit a periodic "sign of life" heartbeat to stderr while a long-running effect
|
|
10
|
+
* is in flight, so operators are not left staring at a silent console during slow
|
|
11
|
+
* Azure provisioning (Container App environments, Cosmos DB, SQL, VMs, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Nothing is logged when the wrapped effect settles before the first interval, so
|
|
14
|
+
* fast paths and the in-memory test mock stay quiet. Apply this selectively to the
|
|
15
|
+
* genuinely slow resources only — do not blanket-wrap every Azure call.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* yield* Effect.tryPromise({ try, catch }).pipe(
|
|
20
|
+
* withHeartbeat(`Container Apps managed environment "${name}"`),
|
|
21
|
+
* );
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export const withHeartbeat =
|
|
25
|
+
(label: string, interval: Duration.Input = Duration.seconds(60)) =>
|
|
26
|
+
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
const tick = Effect.sync(() => {
|
|
30
|
+
const seconds = Math.round((Date.now() - start) / 1000);
|
|
31
|
+
console.error(`[azure] still working on ${label} (${seconds}s elapsed)…`);
|
|
32
|
+
}).pipe(Effect.delay(interval), Effect.forever);
|
|
33
|
+
// Fork the heartbeat loop as a child fiber and guarantee it is interrupted as
|
|
34
|
+
// soon as the operation settles (success, failure, or interruption), so no
|
|
35
|
+
// timer leaks and the console goes quiet the moment work completes.
|
|
36
|
+
const fiber = yield* tick.pipe(Effect.forkChild);
|
|
37
|
+
return yield* self.pipe(Effect.ensuring(Fiber.interrupt(fiber)));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type NamedResourceGroup = string | ResourceGroup;
|
|
41
|
+
|
|
42
|
+
export interface PhysicalNameOptions {
|
|
43
|
+
maxLength?: number;
|
|
44
|
+
suffixLength?: number;
|
|
45
|
+
lowercase?: boolean;
|
|
46
|
+
delimiter?: string;
|
|
47
|
+
sanitize?: (name: string) => string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const makePhysicalNames = Effect.gen(function* () {
|
|
51
|
+
const stack = yield* Stack;
|
|
52
|
+
const stage = yield* Stage;
|
|
53
|
+
|
|
54
|
+
const defaultName = (
|
|
55
|
+
id: string,
|
|
56
|
+
instanceId: string,
|
|
57
|
+
options: PhysicalNameOptions = {},
|
|
58
|
+
) => {
|
|
59
|
+
const suffixLength = options.suffixLength ?? 16;
|
|
60
|
+
const delimiter = options.delimiter ?? "-";
|
|
61
|
+
const lowercase = options.lowercase ?? false;
|
|
62
|
+
const prefix = `${stack.name}${delimiter}${id}${delimiter}${stage}${delimiter}`;
|
|
63
|
+
const suffix = base32(Buffer.from(instanceId, "hex")).slice(0, suffixLength);
|
|
64
|
+
const raw = `${prefix}${suffix}`;
|
|
65
|
+
const maxLength = options.maxLength ?? 64;
|
|
66
|
+
const truncated = maxLength && raw.length > maxLength
|
|
67
|
+
? `${prefix.slice(0, maxLength - suffix.length)}${suffix}`
|
|
68
|
+
: raw;
|
|
69
|
+
const sanitized = (lowercase ? truncated.toLowerCase() : truncated).replaceAll(
|
|
70
|
+
lowercase ? /[^a-z0-9-]/g : /[^a-zA-Z0-9-]/g,
|
|
71
|
+
delimiter,
|
|
72
|
+
);
|
|
73
|
+
return options.sanitize?.(sanitized) ?? sanitized;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const physicalName = (
|
|
77
|
+
id: string,
|
|
78
|
+
instanceId: string,
|
|
79
|
+
name: string | undefined,
|
|
80
|
+
options?: PhysicalNameOptions,
|
|
81
|
+
) => name ?? defaultName(id, instanceId, options);
|
|
82
|
+
|
|
83
|
+
return { defaultName, physicalName };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const physicalName = (
|
|
87
|
+
id: string,
|
|
88
|
+
instanceId: string,
|
|
89
|
+
name: string | undefined,
|
|
90
|
+
options?: PhysicalNameOptions,
|
|
91
|
+
) => makePhysicalNames.pipe(Effect.map((names) => names.physicalName(id, instanceId, name, options)));
|
|
92
|
+
|
|
93
|
+
function base32(bytes: Uint8Array): string {
|
|
94
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyz234567";
|
|
95
|
+
const outputLength = Math.ceil((bytes.length * 8) / 5);
|
|
96
|
+
const output = Array.from<string>({ length: outputLength });
|
|
97
|
+
let buffer = 0;
|
|
98
|
+
let bits = 0;
|
|
99
|
+
let index = 0;
|
|
100
|
+
|
|
101
|
+
for (const byte of bytes) {
|
|
102
|
+
buffer = (buffer << 8) | byte;
|
|
103
|
+
bits += 8;
|
|
104
|
+
while (bits >= 5) {
|
|
105
|
+
output[index++] = alphabet[(buffer >>> (bits - 5)) & 31];
|
|
106
|
+
bits -= 5;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (bits > 0) {
|
|
110
|
+
output[index++] = alphabet[(buffer << (5 - bits)) & 31];
|
|
111
|
+
}
|
|
112
|
+
return index === outputLength ? output.join("") : output.slice(0, index).join("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const resourceGroupName = (resourceGroup: NamedResourceGroup) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
if (resourceGroup === undefined) return undefined as never;
|
|
118
|
+
if (typeof resourceGroup === "string") return resourceGroup;
|
|
119
|
+
return yield* resolveResourceValue(resourceGroup.name);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export const resourceGroupLocation = (resourceGroup: NamedResourceGroup) =>
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
if (typeof resourceGroup === "string") return undefined;
|
|
125
|
+
return yield* resolveResourceValue(resourceGroup.location);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export function resolveResourceValue<T>(value: T) {
|
|
129
|
+
return Effect.gen(function* () {
|
|
130
|
+
if (Effect.isEffect(value)) return yield* value;
|
|
131
|
+
const maybeOutput = value as { asEffect?: () => Effect.Effect<Effect.Effect<T>> };
|
|
132
|
+
if (typeof maybeOutput?.asEffect === "function") {
|
|
133
|
+
const accessor = yield* maybeOutput.asEffect();
|
|
134
|
+
return yield* accessor;
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const requireLocation = (
|
|
141
|
+
id: string,
|
|
142
|
+
location: string | undefined,
|
|
143
|
+
resourceGroup: NamedResourceGroup,
|
|
144
|
+
) =>
|
|
145
|
+
Effect.gen(function* () {
|
|
146
|
+
const resolved = location ?? (yield* resourceGroupLocation(resourceGroup));
|
|
147
|
+
if (!resolved) {
|
|
148
|
+
throw new Error(`${id} requires location when resourceGroup is a string.`);
|
|
149
|
+
}
|
|
150
|
+
return resolved;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export async function collectAzurePages<T>(source: AsyncIterable<T> | Promise<{ value?: T[] }> | Promise<T[]>): Promise<T[]> {
|
|
154
|
+
const resolved = await source;
|
|
155
|
+
if (isAsyncIterable<T>(resolved)) {
|
|
156
|
+
const items: T[] = [];
|
|
157
|
+
for await (const item of resolved) items.push(item);
|
|
158
|
+
return items;
|
|
159
|
+
}
|
|
160
|
+
return Array.isArray(resolved) ? resolved : (resolved.value ?? []);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function diffValueEqual(left: unknown, right: unknown) {
|
|
164
|
+
return JSON.stringify(stableDiffValue(left)) === JSON.stringify(stableDiffValue(right));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stableDiffValue(value: unknown): unknown {
|
|
168
|
+
if (Redacted.isRedacted(value)) return Redacted.value(value);
|
|
169
|
+
if (Array.isArray(value)) return value.map(stableDiffValue);
|
|
170
|
+
if (value && typeof value === "object") {
|
|
171
|
+
const record = value as Record<string, unknown>;
|
|
172
|
+
return Object.fromEntries(
|
|
173
|
+
Object.keys(record)
|
|
174
|
+
.filter((key) => record[key] !== undefined)
|
|
175
|
+
.sort()
|
|
176
|
+
.map((key) => [key, stableDiffValue(record[key])]),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
183
|
+
return typeof (value as { [Symbol.asyncIterator]?: unknown })?.[Symbol.asyncIterator] === "function";
|
|
184
|
+
}
|