@meshxdata/fops 0.1.45 → 0.1.46

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -18
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +81 -5
  4. package/src/commands/setup.js +45 -4
  5. package/src/plugins/bundled/fops-plugin-azure/index.js +29 -0
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +1185 -0
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +1180 -0
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-ingress.js +393 -0
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +104 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-network.js +296 -0
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +768 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-reconcilers.js +538 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +849 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-stacks.js +643 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-state.js +145 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +496 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-terraform.js +1032 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +155 -4245
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +186 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +5 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +2 -1
  24. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
  25. package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
  26. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
  27. package/src/ui/tui/App.js +13 -13
  28. package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
  29. package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
  30. package/src/web/dist/index.html +2 -2
  31. package/src/web/server.js +4 -4
  32. package/src/web/dist/assets/index-BphVaAUd.css +0 -1
  33. package/src/web/dist/assets/index-CSckLzuG.js +0 -129
@@ -0,0 +1,1032 @@
1
+ /**
2
+ * azure-aks-terraform.js - Terraform HCL generation from live cluster state
3
+ *
4
+ * Depends on: azure-aks-naming.js, azure-aks-state.js
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import {
10
+ OK, DIM,
11
+ banner, hint, kvLine, subArgs,
12
+ lazyExeca, ensureAzCli, ensureAzAuth,
13
+ } from "./azure.js";
14
+ import { requireCluster } from "./azure-aks-state.js";
15
+
16
+ // ── aks terraform ─────────────────────────────────────────────────────────────
17
+
18
+ export async function aksTerraform(opts = {}) {
19
+ const execa = await lazyExeca();
20
+ const sub = opts.profile;
21
+ await ensureAzCli(execa);
22
+ await ensureAzAuth(execa, { subscription: sub });
23
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
24
+
25
+ banner(`AKS Terraform: ${clusterName}`);
26
+ hint("Fetching cluster details from Azure…");
27
+
28
+ const { stdout: showJson } = await execa("az", [
29
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "json",
30
+ ...subArgs(sub),
31
+ ], { timeout: 30000 });
32
+ const cluster = JSON.parse(showJson);
33
+
34
+ // Fetch resource group details for location
35
+ let rgLocation = cluster.location;
36
+ try {
37
+ const { stdout: rgJson } = await execa("az", [
38
+ "group", "show", "--name", rg, "--output", "json", ...subArgs(sub),
39
+ ], { timeout: 15000 });
40
+ rgLocation = JSON.parse(rgJson).location || rgLocation;
41
+ } catch { /* use cluster location */ }
42
+
43
+ // Discover companion resources in parallel
44
+ hint("Discovering companion resources…");
45
+ const serverName = `fops-${clusterName}-psql`;
46
+ const vaultName = `fops-${clusterName}-kv`.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 24);
47
+
48
+ const [pgResult, kvResult, fluxExtResult, fluxCfgResult, storageResult, acrResult] = await Promise.allSettled([
49
+ execa("az", [
50
+ "postgres", "flexible-server", "show",
51
+ "--name", serverName, "--resource-group", rg,
52
+ "--output", "json", ...subArgs(sub),
53
+ ], { reject: false, timeout: 30000 }),
54
+ execa("az", [
55
+ "keyvault", "show", "--name", vaultName,
56
+ "--output", "json", ...subArgs(sub),
57
+ ], { reject: false, timeout: 30000 }),
58
+ execa("az", [
59
+ "k8s-extension", "show",
60
+ "--resource-group", rg, "--cluster-name", clusterName,
61
+ "--cluster-type", "managedClusters", "--name", "flux",
62
+ "--output", "json", ...subArgs(sub),
63
+ ], { reject: false, timeout: 30000 }),
64
+ execa("az", [
65
+ "k8s-configuration", "flux", "list",
66
+ "--resource-group", rg, "--cluster-name", clusterName,
67
+ "--cluster-type", "managedClusters",
68
+ "--output", "json", ...subArgs(sub),
69
+ ], { reject: false, timeout: 30000 }),
70
+ execa("az", [
71
+ "storage", "account", "list",
72
+ "--resource-group", rg,
73
+ "--output", "json", ...subArgs(sub),
74
+ ], { reject: false, timeout: 30000 }),
75
+ execa("az", [
76
+ "acr", "list",
77
+ "--resource-group", rg,
78
+ "--output", "json", ...subArgs(sub),
79
+ ], { reject: false, timeout: 30000 }),
80
+ ]);
81
+
82
+ const pgServer = pgResult.status === "fulfilled" && pgResult.value.exitCode === 0 && pgResult.value.stdout?.trim()
83
+ ? JSON.parse(pgResult.value.stdout) : null;
84
+ const keyVault = kvResult.status === "fulfilled" && kvResult.value.exitCode === 0 && kvResult.value.stdout?.trim()
85
+ ? JSON.parse(kvResult.value.stdout) : null;
86
+ const fluxExt = fluxExtResult.status === "fulfilled" && fluxExtResult.value.exitCode === 0 && fluxExtResult.value.stdout?.trim()
87
+ ? JSON.parse(fluxExtResult.value.stdout) : null;
88
+ const fluxConfigs = fluxCfgResult.status === "fulfilled" && fluxCfgResult.value.exitCode === 0 && fluxCfgResult.value.stdout?.trim()
89
+ ? JSON.parse(fluxCfgResult.value.stdout) : [];
90
+ const storageAccounts = storageResult.status === "fulfilled" && storageResult.value.exitCode === 0 && storageResult.value.stdout?.trim()
91
+ ? JSON.parse(storageResult.value.stdout) : [];
92
+ const containerRegistries = acrResult.status === "fulfilled" && acrResult.value.exitCode === 0 && acrResult.value.stdout?.trim()
93
+ ? JSON.parse(acrResult.value.stdout) : [];
94
+
95
+ const hcl = generateAksTerraform(cluster, { rg, rgLocation, pgServer, keyVault, fluxExt, fluxConfigs, storageAccounts, containerRegistries });
96
+
97
+ console.log(OK(" ✓ Terraform HCL generated from live cluster state\n"));
98
+
99
+ if (opts.output) {
100
+ fs.mkdirSync(path.dirname(path.resolve(opts.output)), { recursive: true });
101
+ fs.writeFileSync(path.resolve(opts.output), hcl, "utf8");
102
+ console.log(OK(` ✓ Written to ${opts.output}\n`));
103
+ } else {
104
+ console.log(hcl);
105
+ }
106
+ }
107
+
108
+ // ── Generate Terraform HCL ────────────────────────────────────────────────────
109
+
110
+ export function generateAksTerraform(cluster, { rg, rgLocation, pgServer, keyVault, fluxExt, fluxConfigs, storageAccounts = [], containerRegistries = [] }) {
111
+ const sku = cluster.sku || {};
112
+ const netProfile = cluster.networkProfile || {};
113
+ const identity = cluster.identity || {};
114
+ const pools = cluster.agentPoolProfiles || [];
115
+ const defaultPool = pools.find((p) => p.mode === "System") || pools[0];
116
+ const extraPools = pools.filter((p) => p !== defaultPool);
117
+ const apiAccess = cluster.apiServerAccessProfile || {};
118
+ const autoUpgrade = cluster.autoUpgradeProfile || {};
119
+
120
+ const tfName = (cluster.name || "aks").replace(/[^a-zA-Z0-9_]/g, "_");
121
+
122
+ const lines = [];
123
+ const w = (s = "") => lines.push(s);
124
+
125
+ // ── variables ──────────────────────────────────────────────────────────────
126
+
127
+ w(`# ─── Variables ────────────────────────────────────────────────────────────────`);
128
+ w();
129
+ w(`variable "resource_group_name" {`);
130
+ w(` description = "Name of the Azure resource group"`);
131
+ w(` type = string`);
132
+ w(` default = "${rg}"`);
133
+ w(`}`);
134
+ w();
135
+ w(`variable "location" {`);
136
+ w(` description = "Azure region"`);
137
+ w(` type = string`);
138
+ w(` default = "${rgLocation}"`);
139
+ w(`}`);
140
+ w();
141
+ w(`variable "cluster_name" {`);
142
+ w(` description = "AKS cluster name"`);
143
+ w(` type = string`);
144
+ w(` default = "${cluster.name}"`);
145
+ w(`}`);
146
+ w();
147
+ w(`variable "kubernetes_version" {`);
148
+ w(` description = "Kubernetes version"`);
149
+ w(` type = string`);
150
+ w(` default = "${cluster.kubernetesVersion}"`);
151
+ w(`}`);
152
+ w();
153
+ w(`variable "dns_prefix" {`);
154
+ w(` description = "DNS prefix for the cluster"`);
155
+ w(` type = string`);
156
+ w(` default = "${cluster.dnsPrefix || cluster.name}"`);
157
+ w(`}`);
158
+ w();
159
+ w(`variable "sku_tier" {`);
160
+ w(` description = "AKS SKU tier (Free, Standard, Premium)"`);
161
+ w(` type = string`);
162
+ w(` default = "${sku.tier || "Standard"}"`);
163
+ w(`}`);
164
+ w();
165
+
166
+ if (defaultPool) {
167
+ w(`variable "default_node_pool_vm_size" {`);
168
+ w(` description = "VM size for the default node pool"`);
169
+ w(` type = string`);
170
+ w(` default = "${defaultPool.vmSize}"`);
171
+ w(`}`);
172
+ w();
173
+ w(`variable "default_node_pool_count" {`);
174
+ w(` description = "Node count for the default pool"`);
175
+ w(` type = number`);
176
+ w(` default = ${defaultPool.count}`);
177
+ w(`}`);
178
+ w();
179
+ if (defaultPool.enableAutoScaling) {
180
+ w(`variable "default_node_pool_min_count" {`);
181
+ w(` description = "Autoscaler minimum node count"`);
182
+ w(` type = number`);
183
+ w(` default = ${defaultPool.minCount}`);
184
+ w(`}`);
185
+ w();
186
+ w(`variable "default_node_pool_max_count" {`);
187
+ w(` description = "Autoscaler maximum node count"`);
188
+ w(` type = number`);
189
+ w(` default = ${defaultPool.maxCount}`);
190
+ w(`}`);
191
+ w();
192
+ }
193
+ }
194
+
195
+ if (pgServer) {
196
+ w(`variable "postgres_admin_login" {`);
197
+ w(` description = "Postgres administrator login"`);
198
+ w(` type = string`);
199
+ w(` default = "${pgServer.administratorLogin || "fopsadmin"}"`);
200
+ w(`}`);
201
+ w();
202
+ w(`variable "postgres_admin_password" {`);
203
+ w(` description = "Postgres administrator password"`);
204
+ w(` type = string`);
205
+ w(` sensitive = true`);
206
+ w(`}`);
207
+ w();
208
+ }
209
+
210
+ if (fluxConfigs?.length) {
211
+ w(`variable "flux_github_token" {`);
212
+ w(` description = "GitHub PAT for Flux HTTPS access"`);
213
+ w(` type = string`);
214
+ w(` sensitive = true`);
215
+ w(` default = ""`);
216
+ w(`}`);
217
+ w();
218
+ }
219
+
220
+ if (keyVault) {
221
+ w(`variable "tenant_id" {`);
222
+ w(` description = "Azure AD tenant ID for Key Vault"`);
223
+ w(` type = string`);
224
+ w(` default = "${keyVault.properties?.tenantId || ""}"`);
225
+ w(`}`);
226
+ w();
227
+ w(`variable "auth0_client_id" {`);
228
+ w(` description = "Auth0 application client ID"`);
229
+ w(` type = string`);
230
+ w(` default = ""`);
231
+ w(`}`);
232
+ w();
233
+ w(`variable "auth0_client_secret" {`);
234
+ w(` description = "Auth0 application client secret"`);
235
+ w(` type = string`);
236
+ w(` sensitive = true`);
237
+ w(` default = ""`);
238
+ w(`}`);
239
+ w();
240
+ w(`variable "auth0_domain" {`);
241
+ w(` description = "Auth0 tenant domain (e.g., your-tenant.auth0.com)"`);
242
+ w(` type = string`);
243
+ w(` default = ""`);
244
+ w(`}`);
245
+ w();
246
+ w(`variable "auth0_audience" {`);
247
+ w(` description = "Auth0 API audience (e.g., https://api.meshx.app)"`);
248
+ w(` type = string`);
249
+ w(` default = ""`);
250
+ w(`}`);
251
+ w();
252
+ }
253
+
254
+ w(`variable "cloudflare_api_token" {`);
255
+ w(` description = "Cloudflare API token for DNS management"`);
256
+ w(` type = string`);
257
+ w(` sensitive = true`);
258
+ w(` default = ""`);
259
+ w(`}`);
260
+ w();
261
+ w(`variable "dns_zone_name" {`);
262
+ w(` description = "Cloudflare DNS zone name (e.g., meshx.app)"`);
263
+ w(` type = string`);
264
+ w(` default = ""`);
265
+ w(`}`);
266
+ w();
267
+ w(`variable "dns_hostname" {`);
268
+ w(` description = "DNS hostname for the cluster ingress (e.g., demo.meshx.app)"`);
269
+ w(` type = string`);
270
+ w(` default = ""`);
271
+ w(`}`);
272
+ w();
273
+
274
+ // ── provider ───────────────────────────────────────────────────────────────
275
+
276
+ w(`# ─── Provider ─────────────────────────────────────────────────────────────────`);
277
+ w();
278
+ w(`terraform {`);
279
+ w(` required_providers {`);
280
+ w(` azurerm = {`);
281
+ w(` source = "hashicorp/azurerm"`);
282
+ w(` version = "~> 4.0"`);
283
+ w(` }`);
284
+ w(` cloudflare = {`);
285
+ w(` source = "cloudflare/cloudflare"`);
286
+ w(` version = "~> 4.0"`);
287
+ w(` }`);
288
+ w(` random = {`);
289
+ w(` source = "hashicorp/random"`);
290
+ w(` version = "~> 3.0"`);
291
+ w(` }`);
292
+ w(` }`);
293
+ w(`}`);
294
+ w();
295
+ w(`provider "azurerm" {`);
296
+ w(` features {}`);
297
+ w(`}`);
298
+ w();
299
+ w(`provider "cloudflare" {`);
300
+ w(` api_token = var.cloudflare_api_token`);
301
+ w(`}`);
302
+ w();
303
+
304
+ // ── resource group ─────────────────────────────────────────────────────────
305
+
306
+ w(`# ─── Resource Group ──────────────────────────────────────────────────────────`);
307
+ w();
308
+ w(`resource "azurerm_resource_group" "aks" {`);
309
+ w(` name = var.resource_group_name`);
310
+ w(` location = var.location`);
311
+ w(`}`);
312
+ w();
313
+
314
+ // ── AKS cluster ────────────────────────────────────────────────────────────
315
+
316
+ w(`# ─── AKS Cluster ─────────────────────────────────────────────────────────────`);
317
+ w();
318
+ w(`resource "azurerm_kubernetes_cluster" "${tfName}" {`);
319
+ w(` name = var.cluster_name`);
320
+ w(` location = azurerm_resource_group.aks.location`);
321
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
322
+ w(` dns_prefix = var.dns_prefix`);
323
+ w(` kubernetes_version = var.kubernetes_version`);
324
+ w(` sku_tier = var.sku_tier`);
325
+ w();
326
+
327
+ if (apiAccess.authorizedIpRanges?.length) {
328
+ w(` api_server_access_profile {`);
329
+ w(` authorized_ip_ranges = [${apiAccess.authorizedIpRanges.map((r) => `"${r}"`).join(", ")}]`);
330
+ w(` }`);
331
+ w();
332
+ }
333
+
334
+ if (autoUpgrade.upgradeChannel) {
335
+ w(` automatic_upgrade_channel = "${autoUpgrade.upgradeChannel}"`);
336
+ w();
337
+ }
338
+
339
+ if (identity.type) {
340
+ const identityType = identity.type === "SystemAssigned" ? "SystemAssigned" : "UserAssigned";
341
+ w(` identity {`);
342
+ w(` type = "${identityType}"`);
343
+ w(` }`);
344
+ w();
345
+ }
346
+
347
+ if (defaultPool) {
348
+ w(` default_node_pool {`);
349
+ w(` name = "${defaultPool.name}"`);
350
+ w(` vm_size = var.default_node_pool_vm_size`);
351
+ w(` node_count = var.default_node_pool_count`);
352
+ if (defaultPool.enableAutoScaling) {
353
+ w(` auto_scaling_enabled = true`);
354
+ w(` min_count = var.default_node_pool_min_count`);
355
+ w(` max_count = var.default_node_pool_max_count`);
356
+ }
357
+ if (defaultPool.maxPods) {
358
+ w(` max_pods = ${defaultPool.maxPods}`);
359
+ }
360
+ if (defaultPool.osDiskSizeGb) {
361
+ w(` os_disk_size_gb = ${defaultPool.osDiskSizeGb}`);
362
+ }
363
+ if (defaultPool.osDiskType && defaultPool.osDiskType !== "Managed") {
364
+ w(` os_disk_type = "${defaultPool.osDiskType}"`);
365
+ }
366
+ if (defaultPool.vnetSubnetId) {
367
+ w(` vnet_subnet_id = "${defaultPool.vnetSubnetId}"`);
368
+ }
369
+ if (defaultPool.availabilityZones?.length) {
370
+ w(` zones = [${defaultPool.availabilityZones.map((z) => `"${z}"`).join(", ")}]`);
371
+ }
372
+ w(` }`);
373
+ w();
374
+ }
375
+
376
+ if (netProfile.networkPlugin) {
377
+ w(` network_profile {`);
378
+ w(` network_plugin = "${netProfile.networkPlugin}"`);
379
+ // Only emit network_policy if it's a valid value (not "none")
380
+ if (netProfile.networkPolicy && netProfile.networkPolicy !== "none") {
381
+ w(` network_policy = "${netProfile.networkPolicy}"`);
382
+ }
383
+ if (netProfile.serviceCidr) w(` service_cidr = "${netProfile.serviceCidr}"`);
384
+ if (netProfile.dnsServiceIp) w(` dns_service_ip = "${netProfile.dnsServiceIp}"`);
385
+ if (netProfile.loadBalancerSku) w(` load_balancer_sku = "${netProfile.loadBalancerSku}"`);
386
+ w(` }`);
387
+ w();
388
+ }
389
+
390
+ if (cluster.oidcIssuerProfile?.enabled) {
391
+ w(` oidc_issuer_enabled = true`);
392
+ }
393
+ if (cluster.securityProfile?.workloadIdentity?.enabled) {
394
+ w(` workload_identity_enabled = true`);
395
+ }
396
+
397
+ const tags = cluster.tags || {};
398
+ const tagEntries = Object.entries(tags);
399
+ if (tagEntries.length) {
400
+ w();
401
+ w(` tags = {`);
402
+ for (const [k, v] of tagEntries) {
403
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
404
+ }
405
+ w(` }`);
406
+ }
407
+
408
+ w(`}`);
409
+ w();
410
+
411
+ // ── extra node pools ───────────────────────────────────────────────────────
412
+
413
+ for (const pool of extraPools) {
414
+ const poolTf = pool.name.replace(/[^a-zA-Z0-9_]/g, "_");
415
+ w(`resource "azurerm_kubernetes_cluster_node_pool" "${poolTf}" {`);
416
+ w(` name = "${pool.name}"`);
417
+ w(` kubernetes_cluster_id = azurerm_kubernetes_cluster.${tfName}.id`);
418
+ w(` vm_size = "${pool.vmSize}"`);
419
+ w(` node_count = ${pool.count}`);
420
+ if (pool.enableAutoScaling) {
421
+ w(` auto_scaling_enabled = true`);
422
+ w(` min_count = ${pool.minCount}`);
423
+ w(` max_count = ${pool.maxCount}`);
424
+ }
425
+ if (pool.maxPods) {
426
+ w(` max_pods = ${pool.maxPods}`);
427
+ }
428
+ if (pool.mode) {
429
+ w(` mode = "${pool.mode}"`);
430
+ }
431
+ if (pool.osDiskSizeGb) {
432
+ w(` os_disk_size_gb = ${pool.osDiskSizeGb}`);
433
+ }
434
+ if (pool.scaleSetPriority === "Spot") {
435
+ w(` priority = "Spot"`);
436
+ w(` eviction_policy = "${pool.scaleSetEvictionPolicy || "Delete"}"`);
437
+ if (pool.spotMaxPrice != null && pool.spotMaxPrice !== -1) {
438
+ w(` spot_max_price = ${pool.spotMaxPrice}`);
439
+ }
440
+ }
441
+ if (pool.vnetSubnetId) {
442
+ w(` vnet_subnet_id = "${pool.vnetSubnetId}"`);
443
+ }
444
+ if (pool.availabilityZones?.length) {
445
+ w(` zones = [${pool.availabilityZones.map((z) => `"${z}"`).join(", ")}]`);
446
+ }
447
+ const poolTags = pool.tags || {};
448
+ const poolTagEntries = Object.entries(poolTags);
449
+ if (poolTagEntries.length) {
450
+ w();
451
+ w(` tags = {`);
452
+ for (const [k, v] of poolTagEntries) {
453
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
454
+ }
455
+ w(` }`);
456
+ }
457
+ w(`}`);
458
+ w();
459
+ }
460
+
461
+ // ── Postgres Flexible Server ───────────────────────────────────────────────
462
+
463
+ if (pgServer) {
464
+ const pgTf = (pgServer.name || "psql").replace(/[^a-zA-Z0-9_]/g, "_");
465
+ const pgSku = pgServer.sku || {};
466
+ const pgStorage = pgServer.storage || {};
467
+ w(`# ─── Postgres Flexible Server ─────────────────────────────────────────────────`);
468
+ w();
469
+ w(`resource "azurerm_postgresql_flexible_server" "${pgTf}" {`);
470
+ w(` name = "${pgServer.name}"`);
471
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
472
+ w(` location = azurerm_resource_group.aks.location`);
473
+ w(` version = "${pgServer.version || "16"}"`);
474
+ w(` administrator_login = var.postgres_admin_login`);
475
+ w(` administrator_password = var.postgres_admin_password`);
476
+ // Terraform expects sku_name in format "{tier_prefix}_Standard_{size}" e.g. "B_Standard_B2ms"
477
+ // Azure API returns sku.name as "Standard_B2ms" and sku.tier as "Burstable"
478
+ if (pgSku.name && pgSku.tier) {
479
+ const tierPrefix = pgSku.tier === "Burstable" ? "B" : pgSku.tier === "GeneralPurpose" ? "GP" : pgSku.tier === "MemoryOptimized" ? "MO" : pgSku.tier;
480
+ const skuName = pgSku.name.startsWith(`${tierPrefix}_`) ? pgSku.name : `${tierPrefix}_${pgSku.name}`;
481
+ w(` sku_name = "${skuName}"`);
482
+ } else if (pgSku.name) {
483
+ w(` sku_name = "${pgSku.name}"`);
484
+ }
485
+ if (pgSku.tier) w(` zone = "${pgServer.availabilityZone || "1"}"`);
486
+ if (pgStorage.storageSizeGb) {
487
+ w(` storage_mb = ${pgStorage.storageSizeGb * 1024}`);
488
+ }
489
+ if (pgServer.delegatedSubnetArguments?.subnetArmResourceId) {
490
+ w(` delegated_subnet_id = "${pgServer.delegatedSubnetArguments.subnetArmResourceId}"`);
491
+ }
492
+ if (pgServer.network?.privateDnsZoneArmResourceId) {
493
+ w(` private_dns_zone_id = "${pgServer.network.privateDnsZoneArmResourceId}"`);
494
+ }
495
+ const pgTags = pgServer.tags || {};
496
+ const pgTagEntries = Object.entries(pgTags);
497
+ if (pgTagEntries.length) {
498
+ w();
499
+ w(` tags = {`);
500
+ for (const [k, v] of pgTagEntries) {
501
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
502
+ }
503
+ w(` }`);
504
+ }
505
+ w(`}`);
506
+ w();
507
+ }
508
+
509
+ // ── Key Vault ──────────────────────────────────────────────────────────────
510
+
511
+ if (keyVault) {
512
+ const kvTf = (keyVault.name || "kv").replace(/[^a-zA-Z0-9_]/g, "_");
513
+ w(`# ─── Key Vault ────────────────────────────────────────────────────────────────`);
514
+ w();
515
+ w(`resource "azurerm_key_vault" "${kvTf}" {`);
516
+ w(` name = "${keyVault.name}"`);
517
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
518
+ w(` location = azurerm_resource_group.aks.location`);
519
+ w(` tenant_id = var.tenant_id`);
520
+ w(` sku_name = "${keyVault.properties?.sku?.name || "standard"}"`);
521
+ w(` rbac_authorization_enabled = ${keyVault.properties?.enableRbacAuthorization ?? true}`);
522
+ if (keyVault.properties?.enableSoftDelete !== undefined) {
523
+ w(` soft_delete_retention_days = ${keyVault.properties?.softDeleteRetentionInDays || 90}`);
524
+ }
525
+ if (keyVault.properties?.enablePurgeProtection) {
526
+ w(` purge_protection_enabled = true`);
527
+ }
528
+ w();
529
+ w(` # Network access: private (VNet service endpoint)`);
530
+ w(` network_acls {`);
531
+ w(` default_action = "Deny"`);
532
+ w(` bypass = "AzureServices"`);
533
+ if (defaultPool?.vnetSubnetId) {
534
+ w(` virtual_network_subnet_ids = [azurerm_kubernetes_cluster.${tfName}.default_node_pool[0].vnet_subnet_id]`);
535
+ }
536
+ w(` }`);
537
+ const kvTags = keyVault.tags || {};
538
+ const kvTagEntries = Object.entries(kvTags);
539
+ if (kvTagEntries.length) {
540
+ w();
541
+ w(` tags = {`);
542
+ for (const [k, v] of kvTagEntries) {
543
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
544
+ }
545
+ w(` }`);
546
+ }
547
+ w(`}`);
548
+ w();
549
+
550
+ w(`resource "azurerm_role_assignment" "kv_kubelet_secrets_user" {`);
551
+ w(` scope = azurerm_key_vault.${kvTf}.id`);
552
+ w(` role_definition_name = "Key Vault Secrets User"`);
553
+ w(` principal_id = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
554
+ w(`}`);
555
+ w();
556
+
557
+ w(`resource "azurerm_role_assignment" "kv_kubelet_secrets_officer" {`);
558
+ w(` scope = azurerm_key_vault.${kvTf}.id`);
559
+ w(` role_definition_name = "Key Vault Secrets Officer"`);
560
+ w(` principal_id = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
561
+ w(`}`);
562
+ w();
563
+
564
+ // Vault auto-unseal key and role assignment
565
+ w(`# ─── Vault Auto-Unseal Key ─────────────────────────────────────────────────────`);
566
+ w();
567
+ w(`resource "azurerm_key_vault_key" "vault_unseal" {`);
568
+ w(` name = "vault-unseal"`);
569
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
570
+ w(` key_type = "RSA"`);
571
+ w(` key_size = 2048`);
572
+ w();
573
+ w(` key_opts = [`);
574
+ w(` "wrapKey",`);
575
+ w(` "unwrapKey",`);
576
+ w(` ]`);
577
+ w();
578
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
579
+ w(`}`);
580
+ w();
581
+
582
+ w(`resource "azurerm_role_assignment" "kv_kubelet_crypto_user" {`);
583
+ w(` scope = azurerm_key_vault.${kvTf}.id`);
584
+ w(` role_definition_name = "Key Vault Crypto User"`);
585
+ w(` principal_id = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
586
+ w(`}`);
587
+ w();
588
+ }
589
+
590
+ // ── Key Vault Secrets ────────────────────────────────────────────────────
591
+
592
+ if (keyVault) {
593
+ const kvTf = (keyVault.name || "kv").replace(/[^a-zA-Z0-9_]/g, "_");
594
+ const firstStorage = storageAccounts[0];
595
+ const saTf = firstStorage ? (firstStorage.name || "storage").replace(/[^a-zA-Z0-9_]/g, "_") : null;
596
+
597
+ w(`# ─── Key Vault Secrets ─────────────────────────────────────────────────────────`);
598
+ w();
599
+ w(`resource "random_password" "postgres" {`);
600
+ w(` length = 32`);
601
+ w(` special = false # URL-safe for connection strings`);
602
+ w(`}`);
603
+ w();
604
+ w(`resource "random_password" "secret_key" {`);
605
+ w(` length = 64`);
606
+ w(` special = false`);
607
+ w(`}`);
608
+ w();
609
+ w(`resource "random_password" "auth0_session" {`);
610
+ w(` length = 64`);
611
+ w(` special = false`);
612
+ w(`}`);
613
+ w();
614
+ w(`resource "azurerm_key_vault_secret" "foundation_secrets" {`);
615
+ w(` name = "foundation-secrets"`);
616
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
617
+ w(` content_type = "application/json"`);
618
+ w(` value = jsonencode({`);
619
+ w(` password = random_password.postgres.result`);
620
+ w(` secret_key = random_password.secret_key.result`);
621
+ w(` "auth0.client_id" = var.auth0_client_id`);
622
+ w(` "auth0.client_secret" = var.auth0_client_secret`);
623
+ w(` "auth0.secret" = random_password.auth0_session.result`);
624
+ w(` "auth0.domain" = var.auth0_domain`);
625
+ w(` "auth0.audience" = var.auth0_audience`);
626
+ w(` "auth0.issuer_base_url" = var.auth0_domain != "" ? "https://\${var.auth0_domain}" : ""`);
627
+ w(` "auth0.base_url" = var.dns_hostname != "" ? "https://\${var.dns_hostname}" : ""`);
628
+ w(` })`);
629
+ w();
630
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
631
+ w(`}`);
632
+ w();
633
+ w(`resource "azurerm_key_vault_secret" "auth0" {`);
634
+ w(` name = "auth0"`);
635
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
636
+ w(` content_type = "application/json"`);
637
+ w(` value = jsonencode({`);
638
+ w(` client_id = var.auth0_client_id`);
639
+ w(` client_secret = var.auth0_client_secret`);
640
+ w(` })`);
641
+ w();
642
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
643
+ w(`}`);
644
+ w();
645
+ w(`resource "azurerm_key_vault_secret" "foundation_trino_jwt" {`);
646
+ w(` name = "foundation-trino-jwt"`);
647
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
648
+ w(` content_type = "application/json"`);
649
+ w(` value = jsonencode({`);
650
+ w(` "jwt-secret.pem" = random_password.secret_key.result`);
651
+ w(` secret_key = random_password.secret_key.result`);
652
+ w(` })`);
653
+ w();
654
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
655
+ w(`}`);
656
+ w();
657
+ w(`resource "azurerm_key_vault_secret" "foundation_nats" {`);
658
+ w(` name = "foundation-nats"`);
659
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
660
+ w(` content_type = "application/json"`);
661
+ w(` value = jsonencode({`);
662
+ w(` "nkeys-secret" = random_password.secret_key.result`);
663
+ w(` secret_key = random_password.secret_key.result`);
664
+ w(` })`);
665
+ w();
666
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
667
+ w(`}`);
668
+ w();
669
+ w(`resource "azurerm_key_vault_secret" "foundation_scheduler" {`);
670
+ w(` name = "foundation-scheduler-secrets"`);
671
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
672
+ w(` content_type = "application/json"`);
673
+ w(` value = jsonencode({`);
674
+ w(` secretKey = random_password.secret_key.result`);
675
+ w(` })`);
676
+ w();
677
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
678
+ w(`}`);
679
+ w();
680
+
681
+ if (saTf) {
682
+ w(`resource "azurerm_key_vault_secret" "foundation_storage_engine_secrets" {`);
683
+ w(` name = "foundation-storage-engine-secrets"`);
684
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
685
+ w(` content_type = "application/json"`);
686
+ w(` value = jsonencode({`);
687
+ w(` AZURE_ACCOUNT_NAME = azurerm_storage_account.${saTf}.name`);
688
+ w(` AZURE_ACCOUNT_KEY = azurerm_storage_account.${saTf}.primary_access_key`);
689
+ w(` AZURE_SAS_TOKEN = ""`);
690
+ w(` })`);
691
+ w();
692
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
693
+ w(`}`);
694
+ w();
695
+ }
696
+
697
+ w(`resource "azurerm_key_vault_secret" "foundation_storage_engine_auth" {`);
698
+ w(` name = "foundation-storage-engine-auth"`);
699
+ w(` key_vault_id = azurerm_key_vault.${kvTf}.id`);
700
+ w(` content_type = "application/json"`);
701
+ w(` value = jsonencode({`);
702
+ w(` AUTH_IDENTITY = "foundation"`);
703
+ w(` AUTH_CREDENTIAL = random_password.secret_key.result`);
704
+ w(` })`);
705
+ w();
706
+ w(` depends_on = [azurerm_role_assignment.kv_kubelet_secrets_officer]`);
707
+ w(`}`);
708
+ w();
709
+ }
710
+
711
+ // ── Storage Accounts ──────────────────────────────────────────────────────
712
+
713
+ for (const sa of storageAccounts) {
714
+ const saTf = (sa.name || "storage").replace(/[^a-zA-Z0-9_]/g, "_");
715
+ w(`# ─── Storage Account: ${sa.name} ───────────────────────────────────────────────`);
716
+ w();
717
+ w(`resource "azurerm_storage_account" "${saTf}" {`);
718
+ w(` name = "${sa.name}"`);
719
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
720
+ w(` location = azurerm_resource_group.aks.location`);
721
+ w(` account_tier = "${sa.sku?.tier || "Standard"}"`);
722
+ w(` account_replication_type = "${(sa.sku?.name || "Standard_LRS").replace(/^(Standard|Premium)_/, "")}"`);
723
+ w(` account_kind = "${sa.kind || "StorageV2"}"`);
724
+ if (sa.allowBlobPublicAccess === false) {
725
+ w(` allow_nested_items_to_be_public = false`);
726
+ }
727
+ if (sa.minimumTlsVersion) {
728
+ w(` min_tls_version = "${sa.minimumTlsVersion}"`);
729
+ }
730
+ w();
731
+ w(` # Network access: private (VNet service endpoint)`);
732
+ w(` network_rules {`);
733
+ w(` default_action = "Deny"`);
734
+ w(` bypass = ["AzureServices"]`);
735
+ if (defaultPool?.vnetSubnetId) {
736
+ w(` virtual_network_subnet_ids = [azurerm_kubernetes_cluster.${tfName}.default_node_pool[0].vnet_subnet_id]`);
737
+ }
738
+ w(` }`);
739
+ const saTags = sa.tags || {};
740
+ const saTagEntries = Object.entries(saTags);
741
+ if (saTagEntries.length) {
742
+ w();
743
+ w(` tags = {`);
744
+ for (const [k, v] of saTagEntries) {
745
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
746
+ }
747
+ w(` }`);
748
+ }
749
+ w(`}`);
750
+ w();
751
+
752
+ w(`resource "azurerm_role_assignment" "storage_${saTf}_blob_contributor" {`);
753
+ w(` scope = azurerm_storage_account.${saTf}.id`);
754
+ w(` role_definition_name = "Storage Blob Data Contributor"`);
755
+ w(` principal_id = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
756
+ w(`}`);
757
+ w();
758
+ }
759
+
760
+ // ── Container Registries (ACR) ────────────────────────────────────────────
761
+
762
+ for (const acr of containerRegistries) {
763
+ const acrTf = (acr.name || "acr").replace(/[^a-zA-Z0-9_]/g, "_");
764
+ w(`# ─── Container Registry: ${acr.name} ───────────────────────────────────────────`);
765
+ w();
766
+ w(`resource "azurerm_container_registry" "${acrTf}" {`);
767
+ w(` name = "${acr.name}"`);
768
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
769
+ w(` location = azurerm_resource_group.aks.location`);
770
+ w(` sku = "${acr.sku?.name || "Basic"}"`);
771
+ if (acr.adminUserEnabled) {
772
+ w(` admin_enabled = true`);
773
+ }
774
+ const acrTags = acr.tags || {};
775
+ const acrTagEntries = Object.entries(acrTags);
776
+ if (acrTagEntries.length) {
777
+ w();
778
+ w(` tags = {`);
779
+ for (const [k, v] of acrTagEntries) {
780
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
781
+ }
782
+ w(` }`);
783
+ }
784
+ w(`}`);
785
+ w();
786
+
787
+ w(`resource "azurerm_role_assignment" "acr_${acrTf}_pull" {`);
788
+ w(` scope = azurerm_container_registry.${acrTf}.id`);
789
+ w(` role_definition_name = "AcrPull"`);
790
+ w(` principal_id = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
791
+ w(`}`);
792
+ w();
793
+ }
794
+
795
+ // ── Flux extension ─────────────────────────────────────────────────────────
796
+
797
+ if (fluxExt) {
798
+ w(`# ─── Flux ─────────────────────────────────────────────────────────────────────`);
799
+ w();
800
+ w(`resource "azurerm_kubernetes_cluster_extension" "flux" {`);
801
+ w(` name = "flux"`);
802
+ w(` cluster_id = azurerm_kubernetes_cluster.${tfName}.id`);
803
+ w(` extension_type = "microsoft.flux"`);
804
+ w(`}`);
805
+ w();
806
+ }
807
+
808
+ // ── Flux GitOps configurations ─────────────────────────────────────────────
809
+
810
+ for (const cfg of (fluxConfigs || [])) {
811
+ const cfgTf = (cfg.name || "flux_system").replace(/[^a-zA-Z0-9_]/g, "_");
812
+ const gitRepo = cfg.gitRepository || {};
813
+ const kustomizations = cfg.kustomizations || {};
814
+
815
+ w(`resource "azurerm_kubernetes_flux_configuration" "${cfgTf}" {`);
816
+ w(` name = "${cfg.name}"`);
817
+ w(` cluster_id = azurerm_kubernetes_cluster.${tfName}.id`);
818
+ w(` namespace = "${cfg.namespace || "flux-system"}"`);
819
+ w(` scope = "${cfg.scope || "cluster"}"`);
820
+ w();
821
+ if (gitRepo.url) {
822
+ w(` git_repository {`);
823
+ w(` url = "${gitRepo.url}"`);
824
+ w(` reference_type = "branch"`);
825
+ w(` reference_value = "${gitRepo.repositoryRef?.branch || "main"}"`);
826
+ if (gitRepo.httpsUser) {
827
+ w(` https_user = "${gitRepo.httpsUser}"`);
828
+ w(` https_key_base64 = base64encode(var.flux_github_token)`);
829
+ }
830
+ w(` }`);
831
+ w();
832
+ }
833
+ for (const [ksName, ks] of Object.entries(kustomizations)) {
834
+ w(` kustomizations {`);
835
+ w(` name = "${ksName}"`);
836
+ if (ks.path) w(` path = "${ks.path}"`);
837
+ if (ks.prune != null) w(` garbage_collection_enabled = ${ks.prune}`);
838
+ w(` }`);
839
+ w();
840
+ }
841
+
842
+ w(` depends_on = [azurerm_kubernetes_cluster_extension.flux]`);
843
+ w(`}`);
844
+ w();
845
+ }
846
+
847
+ // ── Workload Identity ─────────────────────────────────────────────────────
848
+
849
+ w(`# ─── Workload Identity ────────────────────────────────────────────────────────`);
850
+ w();
851
+ w(`resource "azurerm_user_assigned_identity" "workload" {`);
852
+ w(` name = "\${var.cluster_name}-workload-identity"`);
853
+ w(` resource_group_name = azurerm_resource_group.aks.name`);
854
+ w(` location = azurerm_resource_group.aks.location`);
855
+ w(`}`);
856
+ w();
857
+
858
+ w(`resource "azurerm_federated_identity_credential" "external_secrets" {`);
859
+ w(` name = "external-secrets"`);
860
+ w(` parent_id = azurerm_user_assigned_identity.workload.id`);
861
+ w(` audience = ["api://AzureADTokenExchange"]`);
862
+ w(` issuer = azurerm_kubernetes_cluster.${tfName}.oidc_issuer_url`);
863
+ w(` subject = "system:serviceaccount:external-secrets:external-secrets"`);
864
+ w(`}`);
865
+ w();
866
+
867
+ w(`resource "azurerm_federated_identity_credential" "cert_manager" {`);
868
+ w(` name = "cert-manager"`);
869
+ w(` parent_id = azurerm_user_assigned_identity.workload.id`);
870
+ w(` audience = ["api://AzureADTokenExchange"]`);
871
+ w(` issuer = azurerm_kubernetes_cluster.${tfName}.oidc_issuer_url`);
872
+ w(` subject = "system:serviceaccount:cert-manager:cert-manager"`);
873
+ w(`}`);
874
+ w();
875
+
876
+ w(`resource "azurerm_federated_identity_credential" "vault" {`);
877
+ w(` name = "vault"`);
878
+ w(` parent_id = azurerm_user_assigned_identity.workload.id`);
879
+ w(` audience = ["api://AzureADTokenExchange"]`);
880
+ w(` issuer = azurerm_kubernetes_cluster.${tfName}.oidc_issuer_url`);
881
+ w(` subject = "system:serviceaccount:foundation:vault"`);
882
+ w(`}`);
883
+ w();
884
+
885
+ if (keyVault) {
886
+ const kvTf = (keyVault.name || "kv").replace(/[^a-zA-Z0-9_]/g, "_");
887
+ w(`resource "azurerm_role_assignment" "kv_workload_secrets_user" {`);
888
+ w(` scope = azurerm_key_vault.${kvTf}.id`);
889
+ w(` role_definition_name = "Key Vault Secrets User"`);
890
+ w(` principal_id = azurerm_user_assigned_identity.workload.principal_id`);
891
+ w(`}`);
892
+ w();
893
+ w(`resource "azurerm_role_assignment" "kv_workload_crypto_user" {`);
894
+ w(` scope = azurerm_key_vault.${kvTf}.id`);
895
+ w(` role_definition_name = "Key Vault Crypto User"`);
896
+ w(` principal_id = azurerm_user_assigned_identity.workload.principal_id`);
897
+ w(`}`);
898
+ w();
899
+ }
900
+
901
+ // ── Cloudflare DNS ────────────────────────────────────────────────────────
902
+
903
+ w(`# ─── Cloudflare DNS ──────────────────────────────────────────────────────────`);
904
+ w();
905
+ w(`data "cloudflare_zone" "main" {`);
906
+ w(` count = var.dns_zone_name != "" ? 1 : 0`);
907
+ w(` name = var.dns_zone_name`);
908
+ w(`}`);
909
+ w();
910
+ w(`resource "cloudflare_record" "ingress" {`);
911
+ w(` count = var.dns_hostname != "" && var.dns_zone_name != "" ? 1 : 0`);
912
+ w(` zone_id = data.cloudflare_zone.main[0].id`);
913
+ w(` name = var.dns_hostname`);
914
+ w(` content = "" # Set to ingress IP after cluster creation`);
915
+ w(` type = "A"`);
916
+ w(` ttl = 1`);
917
+ w(` proxied = true`);
918
+ w();
919
+ w(` lifecycle {`);
920
+ w(` ignore_changes = [content] # IP managed externally`);
921
+ w(` }`);
922
+ w(`}`);
923
+ w();
924
+ w(`resource "cloudflare_zone_settings_override" "ssl" {`);
925
+ w(` count = var.dns_zone_name != "" ? 1 : 0`);
926
+ w(` zone_id = data.cloudflare_zone.main[0].id`);
927
+ w();
928
+ w(` settings {`);
929
+ w(` ssl = "full"`);
930
+ w(` }`);
931
+ w(`}`);
932
+ w();
933
+ w(`resource "cloudflare_ruleset" "origin_rules" {`);
934
+ w(` count = var.dns_hostname != "" && var.dns_zone_name != "" ? 1 : 0`);
935
+ w(` zone_id = data.cloudflare_zone.main[0].id`);
936
+ w(` name = "fops origin rules"`);
937
+ w(` kind = "zone"`);
938
+ w(` phase = "http_request_origin"`);
939
+ w();
940
+ w(` rules {`);
941
+ w(` action = "route"`);
942
+ w(` expression = "(http.host eq \\"\${var.dns_hostname}\\")"`);
943
+ w(` description = "fops: \${var.dns_hostname} → port 443"`);
944
+ w(` enabled = true`);
945
+ w();
946
+ w(` action_parameters {`);
947
+ w(` origin {`);
948
+ w(` port = 443`);
949
+ w(` }`);
950
+ w(` }`);
951
+ w(` }`);
952
+ w(`}`);
953
+ w();
954
+
955
+ // ── outputs ────────────────────────────────────────────────────────────────
956
+
957
+ w(`# ─── Outputs ─────────────────────────────────────────────────────────────────`);
958
+ w();
959
+ w(`output "cluster_name" {`);
960
+ w(` value = azurerm_kubernetes_cluster.${tfName}.name`);
961
+ w(`}`);
962
+ w();
963
+ w(`output "cluster_fqdn" {`);
964
+ w(` value = azurerm_kubernetes_cluster.${tfName}.fqdn`);
965
+ w(`}`);
966
+ w();
967
+ w(`output "kube_config" {`);
968
+ w(` value = azurerm_kubernetes_cluster.${tfName}.kube_config_raw`);
969
+ w(` sensitive = true`);
970
+ w(`}`);
971
+ w();
972
+ if (pgServer) {
973
+ w(`output "postgres_fqdn" {`);
974
+ const pgTf = (pgServer.name || "psql").replace(/[^a-zA-Z0-9_]/g, "_");
975
+ w(` value = azurerm_postgresql_flexible_server.${pgTf}.fqdn`);
976
+ w(`}`);
977
+ w();
978
+ }
979
+ if (keyVault) {
980
+ const kvTf = (keyVault.name || "kv").replace(/[^a-zA-Z0-9_]/g, "_");
981
+ w(`output "key_vault_uri" {`);
982
+ w(` value = azurerm_key_vault.${kvTf}.vault_uri`);
983
+ w(`}`);
984
+ w();
985
+ w(`output "key_vault_name" {`);
986
+ w(` value = azurerm_key_vault.${kvTf}.name`);
987
+ w(`}`);
988
+ w();
989
+ w(`output "vault_unseal_key_name" {`);
990
+ w(` description = "Key Vault key name for Vault auto-unseal"`);
991
+ w(` value = azurerm_key_vault_key.vault_unseal.name`);
992
+ w(`}`);
993
+ w();
994
+ }
995
+ for (const sa of storageAccounts) {
996
+ const saTf = (sa.name || "storage").replace(/[^a-zA-Z0-9_]/g, "_");
997
+ w(`output "storage_${saTf}_blob_endpoint" {`);
998
+ w(` value = azurerm_storage_account.${saTf}.primary_blob_endpoint`);
999
+ w(`}`);
1000
+ w();
1001
+ }
1002
+
1003
+ // ── Identity outputs ────────────────────────────────────────────────────────
1004
+
1005
+ w(`output "kubelet_identity_object_id" {`);
1006
+ w(` description = "Object ID of AKS kubelet managed identity (for role assignments)"`);
1007
+ w(` value = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].object_id`);
1008
+ w(`}`);
1009
+ w();
1010
+ w(`output "kubelet_identity_client_id" {`);
1011
+ w(` description = "Client ID of AKS kubelet managed identity (for ExternalSecrets)"`);
1012
+ w(` value = azurerm_kubernetes_cluster.${tfName}.kubelet_identity[0].client_id`);
1013
+ w(`}`);
1014
+ w();
1015
+ w(`output "oidc_issuer_url" {`);
1016
+ w(` description = "OIDC issuer URL for workload identity federation"`);
1017
+ w(` value = azurerm_kubernetes_cluster.${tfName}.oidc_issuer_url`);
1018
+ w(`}`);
1019
+ w();
1020
+ w(`output "workload_identity_client_id" {`);
1021
+ w(` description = "Client ID of workload identity (for pod annotations)"`);
1022
+ w(` value = azurerm_user_assigned_identity.workload.client_id`);
1023
+ w(`}`);
1024
+ w();
1025
+ w(`output "workload_identity_principal_id" {`);
1026
+ w(` description = "Principal ID of workload identity (for role assignments)"`);
1027
+ w(` value = azurerm_user_assigned_identity.workload.principal_id`);
1028
+ w(`}`);
1029
+ w();
1030
+
1031
+ return lines.join("\n");
1032
+ }