@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
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# alchemy-azure
|
|
2
|
+
|
|
3
|
+
Microsoft Azure providers for [Alchemy v2](https://v2.alchemy.run/).
|
|
4
|
+
|
|
5
|
+
This package follows Alchemy's official custom-provider model: resources are declared with `Resource`, lifecycle implementations are registered with `Provider.effect`, credentials resolve through an `AuthProvider`, and all Azure providers are exposed as a single `Azure.providers()` layer.
|
|
6
|
+
|
|
7
|
+
## Compatibility
|
|
8
|
+
|
|
9
|
+
`@bjorntech/alchemy-azure` tracks the `alchemy` v2 beta line. The trailing number in our beta releases mirrors the `alchemy` beta we were tested against.
|
|
10
|
+
|
|
11
|
+
| `@bjorntech/alchemy-azure` | `alchemy` (peer) | `effect` (peer) | Notes |
|
|
12
|
+
| --------------- | ---------------- | --------------- | ----- |
|
|
13
|
+
| `0.2.0-beta.57` | `2.0.0-beta.57` | `>=4.0.0-beta.84 || >=4.0.0` | Current beta. |
|
|
14
|
+
| `0.1.1-beta.57` | `2.0.0-beta.57` | `>=4.0.0-beta.84 || >=4.0.0` | Blob container fix and heartbeat groundwork. |
|
|
15
|
+
| `0.1.0-beta.57` | `2.0.0-beta.57` | `>=4.0.0-beta.84 || >=4.0.0` | Initial beta.57 compatibility release. |
|
|
16
|
+
| `0.1.0-beta.35` | `2.0.0-beta.35` | `>=4.0.0-beta.60` | Initial public beta. |
|
|
17
|
+
|
|
18
|
+
The `alchemy` peer dependency is exact-pinned to a specific beta because the v2 API is still evolving. The `effect` peer accepts the tested beta line or stable Effect 4. Bump compatibility docs and release metadata together when the tested Alchemy beta changes.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bun add alchemy@2.0.0-beta.57 effect @bjorntech/alchemy-azure
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`alchemy` and `effect` are peer dependencies — install them in your app, not just transitively.
|
|
27
|
+
|
|
28
|
+
`@bjorntech/alchemy-azure` ships raw TypeScript (matching the upstream `alchemy` package) and uses `.ts` import suffixes internally. Your `tsconfig.json` needs `"moduleResolution": "Bundler"` (or `"NodeNext"`) and `"allowImportingTsExtensions": true`. This is the default for Bun, Vite, and tsx; plain `tsc`-without-bundler users will need to set it explicitly.
|
|
29
|
+
|
|
30
|
+
## Alignment with Alchemy v2 principles
|
|
31
|
+
|
|
32
|
+
This package follows the patterns documented at [v2.alchemy.run](https://v2.alchemy.run/):
|
|
33
|
+
|
|
34
|
+
- **Resource declarations** use `Resource<Type, Props, Attributes>` with deterministic physical names via `createPhysicalName`.
|
|
35
|
+
- **Provider implementations** use `Provider.effect(R, Effect.gen(...))` returning `R.Provider.of({ ... })` with a single convergent `reconcile` (observe → ensure → sync → return), idempotent `delete`, optional `diff` (with `isResolved` guards and `stables`), and `read` for state recovery + adoption.
|
|
36
|
+
- **Ownership** is detected via the `alchemy:logical-id` tag (or `alchemyLogicalId` blob metadata for blob containers); foreign resources surface as `Unowned(attrs)` so `--adopt` is required to take them over.
|
|
37
|
+
- **Authentication** follows the `AuthProviderLayer` pattern with `env` and `stored` methods, interactive `Clank` prompts in TTYs, and non-interactive defaults in CI.
|
|
38
|
+
- **Secrets** (storage keys, connection strings, registry passwords, client secrets) are returned as `Redacted<string>`; Container App `Redacted` env values are sent through Container Apps secrets and `secretRef`.
|
|
39
|
+
- **Errors** use a tagged `AzureError` (via `Schema.TaggedErrorClass`) for `Effect.catchTag` interop.
|
|
40
|
+
- **Provider layers** bundle into `Azure.providers()` alongside `ProfileLive`, `CredentialsStoreLive`, and the `AzureAuth` registration — same shape as `Cloudflare.providers()` / `Axiom.providers()`.
|
|
41
|
+
|
|
42
|
+
For a deeper walkthrough, see [`ARCHITECTURE.md`](./ARCHITECTURE.md).
|
|
43
|
+
|
|
44
|
+
## Credentials
|
|
45
|
+
|
|
46
|
+
Two authentication methods are supported via Alchemy's profile system:
|
|
47
|
+
|
|
48
|
+
### `env` (default)
|
|
49
|
+
|
|
50
|
+
Set `AZURE_SUBSCRIPTION_ID`. Authentication uses a service principal when all of these are present:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
AZURE_SUBSCRIPTION_ID=...
|
|
54
|
+
AZURE_TENANT_ID=...
|
|
55
|
+
AZURE_CLIENT_ID=...
|
|
56
|
+
AZURE_CLIENT_SECRET=...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If only `AZURE_SUBSCRIPTION_ID` is set, Azure SDK `DefaultAzureCredential` is used, so `az login`, managed identity, and other Azure SDK credential sources can work.
|
|
60
|
+
|
|
61
|
+
### `stored`
|
|
62
|
+
|
|
63
|
+
Run `alchemy login` and select **Service Principal or Subscription** to walk through an interactive flow that stores credentials under `~/.alchemy/credentials/{profile}/azure-stored.json`. You can store either a Service Principal (tenant + client + secret) or just a subscription id when relying on `DefaultAzureCredential`.
|
|
64
|
+
|
|
65
|
+
CI environments always default to `env` regardless of profile config, so unattended runs work as long as the environment variables are set.
|
|
66
|
+
|
|
67
|
+
## Errors
|
|
68
|
+
|
|
69
|
+
Azure SDK failures inside provider lifecycle methods are wrapped as the tagged `AzureError`, carrying `operation`, `resource`, `statusCode`, `code`, and the original `cause`. The Alchemy engine surfaces them in `plan` / `deploy` output.
|
|
70
|
+
|
|
71
|
+
If you build your own custom resource on top of `@bjorntech/alchemy-azure` clients, you can match `AzureError` with `Effect.catchTag` inside the provider effect:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import * as Effect from "effect/Effect";
|
|
75
|
+
import { AzureError } from "@bjorntech/alchemy-azure";
|
|
76
|
+
|
|
77
|
+
const ensureContainer = Effect.gen(function* () {
|
|
78
|
+
// ...your reconcile body, calling Azure clients via Effect.tryPromise
|
|
79
|
+
}).pipe(
|
|
80
|
+
Effect.catchTag("AzureError", (error) =>
|
|
81
|
+
error.statusCode === 409
|
|
82
|
+
? Effect.logWarning(`Skipping: ${error.resource} already exists`)
|
|
83
|
+
: Effect.fail(error),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
> Note: `Effect.catchTag` does not intercept errors from `yield* Azure.X(...)` directly — resource declarations register on the stack, and the engine runs `reconcile` later. To inspect or recover from `AzureError`, do it inside a custom resource's `reconcile`.
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import * as Alchemy from "alchemy";
|
|
94
|
+
import * as Azure from "@bjorntech/alchemy-azure";
|
|
95
|
+
import * as Effect from "effect/Effect";
|
|
96
|
+
|
|
97
|
+
export default Alchemy.Stack(
|
|
98
|
+
"azure-demo",
|
|
99
|
+
{ providers: Azure.providers() },
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const group = yield* Azure.ResourceGroup("Group", {
|
|
102
|
+
location: "westeurope",
|
|
103
|
+
tags: { app: "azure-demo" },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const storage = yield* Azure.StorageAccount("Storage", {
|
|
107
|
+
resourceGroup: group,
|
|
108
|
+
sku: "Standard_LRS",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const uploads = yield* Azure.BlobContainer("Uploads", {
|
|
112
|
+
storageAccount: storage,
|
|
113
|
+
publicAccess: "None",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
resourceGroup: group.name,
|
|
118
|
+
storageAccount: storage.name,
|
|
119
|
+
uploadsUrl: uploads.url,
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Resources
|
|
126
|
+
|
|
127
|
+
- `ResourceGroup` - Azure Resource Group lifecycle.
|
|
128
|
+
- `ResourceProviderRegistration` - Azure resource provider namespace registration.
|
|
129
|
+
- `StorageAccount` - Azure Storage Account lifecycle with keys and connection string returned as `Redacted` values.
|
|
130
|
+
- `BlobContainer` - Azure Blob container lifecycle.
|
|
131
|
+
- `UserAssignedIdentity` - User-assigned managed identity lifecycle.
|
|
132
|
+
- `VirtualNetwork` - Virtual network and subnet lifecycle.
|
|
133
|
+
- `NetworkSecurityGroup` - Network security group lifecycle.
|
|
134
|
+
- `PublicIPAddress` - Public IP address lifecycle.
|
|
135
|
+
- `CognitiveServices` - Azure AI/Cognitive Services account lifecycle.
|
|
136
|
+
- `ServiceBus` - Service Bus namespace lifecycle.
|
|
137
|
+
- `CosmosDBAccount` - Cosmos DB account lifecycle.
|
|
138
|
+
- `SqlServer` - Azure SQL server lifecycle.
|
|
139
|
+
- `SqlDatabase` - Azure SQL database lifecycle.
|
|
140
|
+
- `KeyVault` - Key Vault lifecycle.
|
|
141
|
+
- `AppServicePlan` - App Service plan lifecycle.
|
|
142
|
+
- `AppService` - App Service web app lifecycle.
|
|
143
|
+
- `FunctionApp` - Function App lifecycle.
|
|
144
|
+
- `StaticWebApp` - Static Web App lifecycle.
|
|
145
|
+
- `ContainerInstance` - Azure Container Instance lifecycle.
|
|
146
|
+
- `ContainerAppEnvironment` - Azure Container Apps managed environment lifecycle.
|
|
147
|
+
- `ContainerRegistry` - Azure Container Registry lifecycle with admin credentials returned as `Redacted` values.
|
|
148
|
+
- `ContainerImage` - Local Docker build and push to Azure Container Registry.
|
|
149
|
+
- `ContainerApp` - Experimental Azure Container Apps runtime host from an explicit image.
|
|
150
|
+
- `VirtualMachine` - Virtual Machine lifecycle.
|
|
151
|
+
|
|
152
|
+
## Experimental Container App Host
|
|
153
|
+
|
|
154
|
+
`ContainerApp` is an experimental v2 `Platform` host. It deploys either an explicit image or an `Azure.ContainerImage` build artifact to Azure Container Apps; pass an external build hash to force a new revision when your build output changes.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const environment =
|
|
158
|
+
yield *
|
|
159
|
+
Azure.ContainerAppEnvironment("Env", {
|
|
160
|
+
resourceGroup: group,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const registry =
|
|
164
|
+
yield *
|
|
165
|
+
Azure.ContainerRegistry("Registry", {
|
|
166
|
+
resourceGroup: group,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const image =
|
|
170
|
+
yield *
|
|
171
|
+
Azure.ContainerImage("Image", {
|
|
172
|
+
registry,
|
|
173
|
+
context: ".",
|
|
174
|
+
buildHash: build.hash,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const api =
|
|
178
|
+
yield *
|
|
179
|
+
Azure.ContainerApp("Api", {
|
|
180
|
+
resourceGroup: group,
|
|
181
|
+
environment,
|
|
182
|
+
image,
|
|
183
|
+
registry,
|
|
184
|
+
buildHash: build.hash,
|
|
185
|
+
targetPort: 3000,
|
|
186
|
+
env: {
|
|
187
|
+
NODE_ENV: "production",
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return api.url;
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Provider Layer
|
|
195
|
+
|
|
196
|
+
Merge Azure with other providers using Effect layers:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import * as Layer from "effect/Layer";
|
|
200
|
+
|
|
201
|
+
providers: Layer.mergeAll(Cloudflare.providers(), Azure.providers());
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Azure Blob State
|
|
205
|
+
|
|
206
|
+
Use an existing Azure Blob container as the Alchemy state backend:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
export default Alchemy.Stack(
|
|
210
|
+
"MyApp",
|
|
211
|
+
{
|
|
212
|
+
providers: Azure.providers(),
|
|
213
|
+
state: Azure.blobState({
|
|
214
|
+
accountName: process.env.AZURE_STORAGE_ACCOUNT!,
|
|
215
|
+
accountKey: process.env.AZURE_STORAGE_KEY!,
|
|
216
|
+
containerName: "alchemy-state",
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
// resources...
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Live Smoke Tests
|
|
226
|
+
|
|
227
|
+
Live Azure smoke tests are opt-in and create real Azure resources. They require `AZURE_SUBSCRIPTION_ID` plus either service principal env vars (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`) or an Azure SDK-compatible login such as `az login`.
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
AZURE_LIVE_TEST=1 bun run smoke:azure
|
|
231
|
+
AZURE_LIVE_TEST=1 AZURE_SMOKE_PREFIX=<prefix-from-run> bun run smoke:azure:nuke
|
|
232
|
+
AZURE_LIVE_NEGATIVE_TEST=1 bun run smoke:azure:negative
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The production and negative smoke runners both execute a scoped `alchemy unsafe nuke` cleanup after `destroy`, targeting only smoke resource groups and using smoke run tags to spare non-smoke groups. Azure then deletes all contained resources. `smoke:azure:nuke` is also exposed for manual cleanup if a smoke process is interrupted before its `finally` block runs.
|
|
236
|
+
|
|
237
|
+
Useful flags:
|
|
238
|
+
|
|
239
|
+
- `AZURE_SMOKE_LOCATION` - Azure region, defaults to `westeurope`.
|
|
240
|
+
- `AZURE_SMOKE_FULL=1` - include quota/cost-sensitive optional resources such as Cosmos DB, SQL, VM, Cognitive Services, Key Vault, and Static Web App. App Service Plan, App Service, and Function App are included in the default smoke stack.
|
|
241
|
+
- `AZURE_SMOKE_BUILD_IMAGE=1` - build and push the included Docker smoke image to ACR; requires Docker.
|
package/env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Required. The Azure subscription resources are created in.
|
|
2
|
+
AZURE_SUBSCRIPTION_ID=your-azure-subscription-id
|
|
3
|
+
|
|
4
|
+
# Service principal credentials. Set all three to authenticate with a service
|
|
5
|
+
# principal. If they are omitted, the Azure SDK DefaultAzureCredential is used
|
|
6
|
+
# (az login, managed identity, environment, etc.).
|
|
7
|
+
AZURE_TENANT_ID=your-azure-tenant-id
|
|
8
|
+
AZURE_CLIENT_ID=your-azure-client-id
|
|
9
|
+
AZURE_CLIENT_SECRET=your-azure-client-secret
|
|
10
|
+
|
|
11
|
+
# Optional. Used by the Azure Blob state backend example (Azure.blobState).
|
|
12
|
+
AZURE_STORAGE_ACCOUNT=your-storage-account-name
|
|
13
|
+
AZURE_STORAGE_KEY=your-storage-account-key
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bjorntech/alchemy-azure",
|
|
3
|
+
"version": "0.2.0-beta.57",
|
|
4
|
+
"description": "Microsoft Azure providers for Alchemy v2",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"keywords": [
|
|
9
|
+
"alchemy",
|
|
10
|
+
"alchemy-effect",
|
|
11
|
+
"azure",
|
|
12
|
+
"iac",
|
|
13
|
+
"infrastructure-as-code",
|
|
14
|
+
"effect",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/bjorntech/alchemy-azure.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/bjorntech/alchemy-azure#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/bjorntech/alchemy-azure/issues"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"README.md",
|
|
31
|
+
"ARCHITECTURE.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"env.example",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./src/index.ts",
|
|
39
|
+
"import": "./src/index.ts"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "tsc --noEmit",
|
|
44
|
+
"coverage": "bun test --coverage --coverage-reporter=lcov",
|
|
45
|
+
"coverage:check": "bun run scripts/coverage-floor.ts",
|
|
46
|
+
"crap": "bun run scripts/crap-index.ts",
|
|
47
|
+
"smoke:azure": "bun run scripts/azure-production-smoke.ts",
|
|
48
|
+
"smoke:azure:nuke": "bun run scripts/azure-smoke-nuke.ts",
|
|
49
|
+
"smoke:azure:negative": "bun run scripts/azure-negative-smoke.ts",
|
|
50
|
+
"test": "bun test",
|
|
51
|
+
"format": "oxfmt .",
|
|
52
|
+
"prepublishOnly": "bun run check && bun test && bun run coverage:check"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@azure/arm-appcontainers": "^3.0.0",
|
|
56
|
+
"@azure/arm-appservice": "^17.0.0",
|
|
57
|
+
"@azure/arm-cognitiveservices": "^7.1.0",
|
|
58
|
+
"@azure/arm-compute": "^23.0.0",
|
|
59
|
+
"@azure/arm-containerinstance": "^9.1.0",
|
|
60
|
+
"@azure/arm-containerregistry": "^12.0.0",
|
|
61
|
+
"@azure/arm-cosmosdb": "^9.1.0",
|
|
62
|
+
"@azure/arm-keyvault": "^5.0.0",
|
|
63
|
+
"@azure/arm-msi": "^2.1.0",
|
|
64
|
+
"@azure/arm-network": "^34.0.0",
|
|
65
|
+
"@azure/arm-resources": "^6.1.0",
|
|
66
|
+
"@azure/arm-servicebus": "^6.1.0",
|
|
67
|
+
"@azure/arm-sql": "^10.0.0",
|
|
68
|
+
"@azure/arm-storage": "^18.5.0",
|
|
69
|
+
"@azure/identity": "^4.13.0",
|
|
70
|
+
"@azure/ms-rest-js": "^2.7.0",
|
|
71
|
+
"@azure/storage-blob": "^12.29.1"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"alchemy": "2.0.0-beta.57",
|
|
75
|
+
"effect": ">=4.0.0-beta.84 || >=4.0.0"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@effect/platform-bun": "4.0.0-beta.84",
|
|
79
|
+
"@effect/platform-node": "4.0.0-beta.84",
|
|
80
|
+
"@effect/platform-node-shared": "4.0.0-beta.84",
|
|
81
|
+
"@types/bun": "latest",
|
|
82
|
+
"alchemy": "2.0.0-beta.57",
|
|
83
|
+
"effect": "4.0.0-beta.84",
|
|
84
|
+
"oxfmt": "latest",
|
|
85
|
+
"typescript": "latest"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import * as Console from "effect/Console";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Match from "effect/Match";
|
|
4
|
+
import * as Redacted from "effect/Redacted";
|
|
5
|
+
import {
|
|
6
|
+
AuthError,
|
|
7
|
+
AuthProviderLayer,
|
|
8
|
+
type ConfigureContext,
|
|
9
|
+
} from "alchemy/Auth/AuthProvider";
|
|
10
|
+
import { CredentialsStore, displayRedacted } from "alchemy/Auth/Credentials";
|
|
11
|
+
import { getEnv, getEnvRedacted, retryOnce } from "alchemy/Auth/Env";
|
|
12
|
+
import * as Clank from "alchemy/Util/Clank";
|
|
13
|
+
|
|
14
|
+
export const AZURE_AUTH_PROVIDER_NAME = "Azure";
|
|
15
|
+
|
|
16
|
+
export const AZURE_AUTH_STORAGE_KEY = "azure-stored";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Azure auth methods supported by `alchemy login`:
|
|
20
|
+
*
|
|
21
|
+
* - `env` — read AZURE_SUBSCRIPTION_ID + (optional) AZURE_TENANT_ID,
|
|
22
|
+
* AZURE_CLIENT_ID, AZURE_CLIENT_SECRET. Falls back to
|
|
23
|
+
* `DefaultAzureCredential` when only the subscription is set, so
|
|
24
|
+
* `az login` and managed identity also work.
|
|
25
|
+
* - `stored` — Service Principal credentials saved under
|
|
26
|
+
* `~/.alchemy/credentials/{profile}/azure-stored.json`. Only the
|
|
27
|
+
* subscription id is required; the rest is optional and unlocks
|
|
28
|
+
* service-principal auth when supplied.
|
|
29
|
+
*/
|
|
30
|
+
export type AzureAuthConfig =
|
|
31
|
+
| { method: "env" }
|
|
32
|
+
| { method: "stored" };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Persisted shape for `method: "stored"`. Only `subscriptionId` is
|
|
36
|
+
* required; supply `tenantId` + `clientId` + `clientSecret` to
|
|
37
|
+
* authenticate as a Service Principal, otherwise the SDK falls back
|
|
38
|
+
* to `DefaultAzureCredential` resolved via the SDK's own credential
|
|
39
|
+
* chain.
|
|
40
|
+
*/
|
|
41
|
+
export interface AzureStoredCredentials {
|
|
42
|
+
subscriptionId: string;
|
|
43
|
+
tenantId?: string;
|
|
44
|
+
clientId?: string;
|
|
45
|
+
clientSecret?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type AzureResolvedCredentials =
|
|
49
|
+
| {
|
|
50
|
+
method: "servicePrincipal";
|
|
51
|
+
subscriptionId: string;
|
|
52
|
+
tenantId: string;
|
|
53
|
+
clientId: string;
|
|
54
|
+
clientSecret: Redacted.Redacted<string>;
|
|
55
|
+
source: { type: AzureAuthConfig["method"] };
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
method: "default";
|
|
59
|
+
subscriptionId: string;
|
|
60
|
+
tenantId?: string;
|
|
61
|
+
source: { type: AzureAuthConfig["method"] };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve {@link AzureResolvedCredentials} from environment variables.
|
|
66
|
+
*
|
|
67
|
+
* Returns `servicePrincipal` when AZURE_TENANT_ID + AZURE_CLIENT_ID +
|
|
68
|
+
* AZURE_CLIENT_SECRET are all set; otherwise returns `default` so the
|
|
69
|
+
* Azure SDK's `DefaultAzureCredential` chain (managed identity,
|
|
70
|
+
* `az login`, etc.) is used. Fails with {@link AuthError} when
|
|
71
|
+
* AZURE_SUBSCRIPTION_ID is missing.
|
|
72
|
+
*
|
|
73
|
+
* Exported for testing and for callers that want to skip the
|
|
74
|
+
* AuthProvider registry.
|
|
75
|
+
*/
|
|
76
|
+
export const resolveFromEnv = (): Effect.Effect<AzureResolvedCredentials, AuthError> =>
|
|
77
|
+
Effect.gen(function* () {
|
|
78
|
+
const subscriptionId = yield* getEnv("AZURE_SUBSCRIPTION_ID");
|
|
79
|
+
if (!subscriptionId) {
|
|
80
|
+
return yield* new AuthError({
|
|
81
|
+
message: "Azure env credentials not found. Set AZURE_SUBSCRIPTION_ID.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const tenantId = yield* getEnv("AZURE_TENANT_ID");
|
|
85
|
+
const clientId = yield* getEnv("AZURE_CLIENT_ID");
|
|
86
|
+
const clientSecret = yield* getEnvRedacted("AZURE_CLIENT_SECRET");
|
|
87
|
+
|
|
88
|
+
if (tenantId && clientId && clientSecret) {
|
|
89
|
+
const resolved: AzureResolvedCredentials = {
|
|
90
|
+
method: "servicePrincipal",
|
|
91
|
+
subscriptionId,
|
|
92
|
+
tenantId,
|
|
93
|
+
clientId,
|
|
94
|
+
clientSecret,
|
|
95
|
+
source: { type: "env" },
|
|
96
|
+
};
|
|
97
|
+
return resolved;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const resolved: AzureResolvedCredentials = {
|
|
101
|
+
method: "default",
|
|
102
|
+
subscriptionId,
|
|
103
|
+
tenantId: tenantId ?? undefined,
|
|
104
|
+
source: { type: "env" },
|
|
105
|
+
};
|
|
106
|
+
return resolved;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve {@link AzureResolvedCredentials} from a previously-stored
|
|
111
|
+
* `AzureStoredCredentials` payload (typically loaded from
|
|
112
|
+
* `~/.alchemy/credentials/{profile}/azure-stored.json`). Pass
|
|
113
|
+
* `undefined` to surface the "credentials not found" error.
|
|
114
|
+
*
|
|
115
|
+
* Exported for testing.
|
|
116
|
+
*/
|
|
117
|
+
export const resolveFromStored = (
|
|
118
|
+
creds: AzureStoredCredentials | undefined,
|
|
119
|
+
): Effect.Effect<AzureResolvedCredentials, AuthError> =>
|
|
120
|
+
Effect.gen(function* () {
|
|
121
|
+
if (creds == null) {
|
|
122
|
+
return yield* new AuthError({
|
|
123
|
+
message: "Azure stored credentials not found. Run: alchemy login --configure",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (creds.tenantId && creds.clientId && creds.clientSecret) {
|
|
127
|
+
const resolved: AzureResolvedCredentials = {
|
|
128
|
+
method: "servicePrincipal",
|
|
129
|
+
subscriptionId: creds.subscriptionId,
|
|
130
|
+
tenantId: creds.tenantId,
|
|
131
|
+
clientId: creds.clientId,
|
|
132
|
+
clientSecret: Redacted.make(creds.clientSecret),
|
|
133
|
+
source: { type: "stored" },
|
|
134
|
+
};
|
|
135
|
+
return resolved;
|
|
136
|
+
}
|
|
137
|
+
const resolved: AzureResolvedCredentials = {
|
|
138
|
+
method: "default",
|
|
139
|
+
subscriptionId: creds.subscriptionId,
|
|
140
|
+
tenantId: creds.tenantId,
|
|
141
|
+
source: { type: "stored" },
|
|
142
|
+
};
|
|
143
|
+
return resolved;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Layer that registers the Azure {@link AuthProvider} into the
|
|
148
|
+
* {@link AuthProviders} registry when built. Included in the Azure
|
|
149
|
+
* `providers()` layer so `alchemy login` can discover it and walk
|
|
150
|
+
* users through interactive credential setup.
|
|
151
|
+
*/
|
|
152
|
+
export const AzureAuth = AuthProviderLayer<
|
|
153
|
+
AzureAuthConfig,
|
|
154
|
+
AzureResolvedCredentials
|
|
155
|
+
>()(
|
|
156
|
+
AZURE_AUTH_PROVIDER_NAME,
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const store = yield* CredentialsStore;
|
|
159
|
+
|
|
160
|
+
const promptStored = Effect.fnUntraced(function* (profileName: string) {
|
|
161
|
+
const subscriptionId = yield* Clank.text({
|
|
162
|
+
message: "Azure Subscription ID",
|
|
163
|
+
placeholder: (yield* getEnv("AZURE_SUBSCRIPTION_ID")) ?? "",
|
|
164
|
+
validate: (v) =>
|
|
165
|
+
v.length === 0
|
|
166
|
+
? "Required"
|
|
167
|
+
: isUuid(v)
|
|
168
|
+
? undefined
|
|
169
|
+
: "Expected a UUID",
|
|
170
|
+
}).pipe(retryOnce);
|
|
171
|
+
|
|
172
|
+
const useServicePrincipal = yield* Clank.confirm({
|
|
173
|
+
message: "Use a Service Principal? (No = use DefaultAzureCredential / az login)",
|
|
174
|
+
initialValue: false,
|
|
175
|
+
}).pipe(retryOnce);
|
|
176
|
+
|
|
177
|
+
let tenantId: string | undefined;
|
|
178
|
+
let clientId: string | undefined;
|
|
179
|
+
let clientSecret: string | undefined;
|
|
180
|
+
|
|
181
|
+
if (useServicePrincipal) {
|
|
182
|
+
tenantId = yield* Clank.text({
|
|
183
|
+
message: "Azure Tenant ID",
|
|
184
|
+
placeholder: (yield* getEnv("AZURE_TENANT_ID")) ?? "",
|
|
185
|
+
validate: (v) => (v.length === 0 ? "Required" : isUuid(v) ? undefined : "Expected a UUID"),
|
|
186
|
+
}).pipe(retryOnce);
|
|
187
|
+
|
|
188
|
+
clientId = yield* Clank.text({
|
|
189
|
+
message: "Azure Client ID",
|
|
190
|
+
placeholder: (yield* getEnv("AZURE_CLIENT_ID")) ?? "",
|
|
191
|
+
validate: (v) => (v.length === 0 ? "Required" : isUuid(v) ? undefined : "Expected a UUID"),
|
|
192
|
+
}).pipe(retryOnce);
|
|
193
|
+
|
|
194
|
+
clientSecret = yield* Clank.password({
|
|
195
|
+
message: "Azure Client Secret",
|
|
196
|
+
validate: (v) => (v.length === 0 ? "Required" : undefined),
|
|
197
|
+
}).pipe(retryOnce);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
yield* store.write<AzureStoredCredentials>(profileName, AZURE_AUTH_STORAGE_KEY, {
|
|
201
|
+
subscriptionId,
|
|
202
|
+
tenantId,
|
|
203
|
+
clientId,
|
|
204
|
+
clientSecret,
|
|
205
|
+
});
|
|
206
|
+
yield* Clank.success("Azure: credentials saved.");
|
|
207
|
+
|
|
208
|
+
return { method: "stored" as const };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const configureCredentials = (profileName: string, ctx: ConfigureContext) =>
|
|
212
|
+
Effect.gen(function* () {
|
|
213
|
+
if (ctx.ci) {
|
|
214
|
+
return { method: "env" as const };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const method = yield* Clank.select({
|
|
218
|
+
message: "Azure authentication method",
|
|
219
|
+
options: [
|
|
220
|
+
{
|
|
221
|
+
value: "env" as const,
|
|
222
|
+
label: "Environment Variables",
|
|
223
|
+
hint: "AZURE_SUBSCRIPTION_ID (+ optional AZURE_TENANT_ID/CLIENT_ID/CLIENT_SECRET)",
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
value: "stored" as const,
|
|
227
|
+
label: "Service Principal or Subscription",
|
|
228
|
+
hint: "enter interactively, stored in ~/.alchemy/credentials",
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
}).pipe(retryOnce);
|
|
232
|
+
|
|
233
|
+
return yield* Match.value(method).pipe(
|
|
234
|
+
Match.when("env", () => Effect.succeed({ method: "env" as const })),
|
|
235
|
+
Match.when("stored", () => promptStored(profileName)),
|
|
236
|
+
Match.exhaustive,
|
|
237
|
+
);
|
|
238
|
+
}).pipe(
|
|
239
|
+
Effect.mapError(
|
|
240
|
+
(e) =>
|
|
241
|
+
new AuthError({
|
|
242
|
+
message: "failed to configure credentials",
|
|
243
|
+
cause: e,
|
|
244
|
+
}),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const resolveCredentials = (
|
|
249
|
+
profileName: string,
|
|
250
|
+
config: AzureAuthConfig,
|
|
251
|
+
): Effect.Effect<AzureResolvedCredentials, AuthError> =>
|
|
252
|
+
Match.value(config).pipe(
|
|
253
|
+
Match.when({ method: "env" }, () => resolveFromEnv()),
|
|
254
|
+
Match.when({ method: "stored" }, () =>
|
|
255
|
+
store
|
|
256
|
+
.read<AzureStoredCredentials>(profileName, AZURE_AUTH_STORAGE_KEY)
|
|
257
|
+
.pipe(Effect.flatMap(resolveFromStored)),
|
|
258
|
+
),
|
|
259
|
+
Match.exhaustive,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const login = (profileName: string, config: AzureAuthConfig) =>
|
|
263
|
+
Match.value(config)
|
|
264
|
+
.pipe(
|
|
265
|
+
Match.when({ method: "env" }, () => Effect.void),
|
|
266
|
+
Match.when({ method: "stored" }, () =>
|
|
267
|
+
store
|
|
268
|
+
.read<AzureStoredCredentials>(profileName, AZURE_AUTH_STORAGE_KEY)
|
|
269
|
+
.pipe(
|
|
270
|
+
Effect.flatMap((creds) =>
|
|
271
|
+
creds == null ? promptStored(profileName).pipe(Effect.asVoid) : Effect.void,
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
Match.exhaustive,
|
|
276
|
+
)
|
|
277
|
+
.pipe(
|
|
278
|
+
Effect.mapError(
|
|
279
|
+
(e) => new AuthError({ message: "login failed", cause: e }),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const logout = (profileName: string, config: AzureAuthConfig) =>
|
|
284
|
+
Match.value(config).pipe(
|
|
285
|
+
Match.when({ method: "env" }, () => Effect.void),
|
|
286
|
+
Match.when({ method: "stored" }, () =>
|
|
287
|
+
store
|
|
288
|
+
.delete(profileName, AZURE_AUTH_STORAGE_KEY)
|
|
289
|
+
.pipe(Effect.andThen(Clank.success("Azure: stored credentials removed"))),
|
|
290
|
+
),
|
|
291
|
+
Match.exhaustive,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const prettyPrint = (profileName: string, config: AzureAuthConfig) =>
|
|
295
|
+
resolveCredentials(profileName, config).pipe(
|
|
296
|
+
Effect.tap((credentials) =>
|
|
297
|
+
Match.value(credentials).pipe(
|
|
298
|
+
Match.when({ method: "servicePrincipal" }, (c) =>
|
|
299
|
+
Effect.all([
|
|
300
|
+
Console.log(` subscriptionId: ${c.subscriptionId}`),
|
|
301
|
+
Console.log(` tenantId: ${c.tenantId}`),
|
|
302
|
+
Console.log(` clientId: ${c.clientId}`),
|
|
303
|
+
Console.log(` clientSecret: ${displayRedacted(c.clientSecret, 4)}`),
|
|
304
|
+
Console.log(` source: ${c.source.type}`),
|
|
305
|
+
]),
|
|
306
|
+
),
|
|
307
|
+
Match.when({ method: "default" }, (c) =>
|
|
308
|
+
Effect.all([
|
|
309
|
+
Console.log(` subscriptionId: ${c.subscriptionId}`),
|
|
310
|
+
Console.log(` tenantId: ${c.tenantId ?? "<auto>"}`),
|
|
311
|
+
Console.log(" credential: DefaultAzureCredential"),
|
|
312
|
+
Console.log(` source: ${c.source.type}`),
|
|
313
|
+
]),
|
|
314
|
+
),
|
|
315
|
+
Match.exhaustive,
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
Effect.catch((e) => Console.error(` Failed to retrieve credentials: ${e}`)),
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
configure: configureCredentials,
|
|
323
|
+
login,
|
|
324
|
+
logout,
|
|
325
|
+
prettyPrint,
|
|
326
|
+
read: resolveCredentials,
|
|
327
|
+
};
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const isUuid = (value: string) =>
|
|
332
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|