@meshxdata/fops 0.1.49 → 0.1.51

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 (30) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/package.json +1 -1
  3. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
  4. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
  5. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
  28. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
  29. package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
  30. package/src/plugins/bundled/fops-plugin-foundation/index.js +371 -44
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Azure VM — Terraform HCL generation
3
+ * Extracted from azure-vm-lifecycle.js for maintainability
4
+ */
5
+ import {
6
+ DEFAULTS, DIM, OK,
7
+ banner, hint, kvLine,
8
+ lazyExeca, ensureAzCli, ensureAzAuth, subArgs,
9
+ } from "./azure-helpers.js";
10
+ import { requireVmState } from "./azure-state.js";
11
+
12
+ // ── terraform ────────────────────────────────────────────────────────────────
13
+
14
+ export async function vmTerraform(opts = {}) {
15
+ const fs = await import("node:fs");
16
+ const path = await import("node:path");
17
+ const execa = await lazyExeca();
18
+ const sub = opts.profile;
19
+ await ensureAzCli(execa);
20
+ await ensureAzAuth(execa, { subscription: sub });
21
+ const state = requireVmState(opts.vmName);
22
+ const { vmName, resourceGroup: rg } = state;
23
+
24
+ banner(`VM Terraform: ${vmName}`);
25
+ hint("Fetching VM and networking details from Azure…");
26
+
27
+ const { stdout: vmJson } = await execa("az", [
28
+ "vm", "show", "-g", rg, "-n", vmName, "--output", "json", ...subArgs(sub),
29
+ ], { timeout: 30000 });
30
+ const vm = JSON.parse(vmJson);
31
+
32
+ let rgLocation = vm.location;
33
+ try {
34
+ const { stdout: rgJson } = await execa("az", [
35
+ "group", "show", "--name", rg, "--output", "json", ...subArgs(sub),
36
+ ], { timeout: 15000 });
37
+ rgLocation = JSON.parse(rgJson).location || rgLocation;
38
+ } catch { /* use vm location */ }
39
+
40
+ // Resolve NIC
41
+ const nicId = vm.networkProfile?.networkInterfaces?.[0]?.id || "";
42
+ const nicName = nicId.split("/").pop();
43
+ let nic = null;
44
+ if (nicName) {
45
+ try {
46
+ const { stdout } = await execa("az", [
47
+ "network", "nic", "show", "-g", rg, "-n", nicName, "--output", "json", ...subArgs(sub),
48
+ ], { timeout: 15000 });
49
+ nic = JSON.parse(stdout);
50
+ } catch { /* no nic */ }
51
+ }
52
+
53
+ let pip = null;
54
+ let nsg = null;
55
+ let nsgRules = [];
56
+ let subnet = null;
57
+ let vnet = null;
58
+
59
+ if (nic) {
60
+ const ipConfig = nic.ipConfigurations?.[0] || {};
61
+ const pipId = ipConfig.publicIPAddress?.id || "";
62
+ const pipName = pipId.split("/").pop();
63
+ const subnetId = ipConfig.subnet?.id || "";
64
+ const nsgId = nic.networkSecurityGroup?.id || "";
65
+ const nsgName = nsgId.split("/").pop();
66
+
67
+ hint("Discovering networking resources…");
68
+ const fetches = [];
69
+
70
+ if (pipName) {
71
+ fetches.push(
72
+ execa("az", ["network", "public-ip", "show", "-g", rg, "-n", pipName, "--output", "json", ...subArgs(sub)],
73
+ { reject: false, timeout: 15000 }).then(r => { if (r.exitCode === 0) pip = JSON.parse(r.stdout); })
74
+ );
75
+ }
76
+ if (nsgName) {
77
+ fetches.push(
78
+ execa("az", ["network", "nsg", "show", "-g", rg, "-n", nsgName, "--output", "json", ...subArgs(sub)],
79
+ { reject: false, timeout: 15000 }).then(r => {
80
+ if (r.exitCode === 0) {
81
+ nsg = JSON.parse(r.stdout);
82
+ nsgRules = (nsg.securityRules || []).filter(rule => rule.direction === "Inbound" && rule.access === "Allow");
83
+ }
84
+ })
85
+ );
86
+ }
87
+ if (subnetId) {
88
+ const parts = subnetId.split("/");
89
+ const vnetName = parts[parts.indexOf("virtualNetworks") + 1];
90
+ const subnetName = parts[parts.indexOf("subnets") + 1];
91
+ const vnetRg = parts[parts.indexOf("resourceGroups") + 1] || rg;
92
+ fetches.push(
93
+ execa("az", ["network", "vnet", "show", "-g", vnetRg, "-n", vnetName, "--output", "json", ...subArgs(sub)],
94
+ { reject: false, timeout: 15000 }).then(r => {
95
+ if (r.exitCode === 0) {
96
+ vnet = JSON.parse(r.stdout);
97
+ subnet = (vnet.subnets || []).find(s => s.name === subnetName) || null;
98
+ }
99
+ })
100
+ );
101
+ }
102
+
103
+ await Promise.allSettled(fetches);
104
+ }
105
+
106
+ // Check for ADE Key Vault
107
+ let adeKeyVault = null;
108
+ const adeVaultName = `fops-ade-kv-${vm.location}`;
109
+ try {
110
+ const { stdout, exitCode } = await execa("az", [
111
+ "keyvault", "show", "--name", adeVaultName, "--output", "json", ...subArgs(sub),
112
+ ], { reject: false, timeout: 15000 });
113
+ if (exitCode === 0 && stdout?.trim()) adeKeyVault = JSON.parse(stdout);
114
+ } catch { /* no vault */ }
115
+
116
+ const hcl = generateVmTerraform(vm, {
117
+ rg, rgLocation, nic, pip, nsg, nsgRules, vnet, subnet, adeKeyVault, state,
118
+ });
119
+
120
+ console.log(OK(" ✓ Terraform HCL generated from live VM state\n"));
121
+
122
+ if (opts.output) {
123
+ fs.mkdirSync(path.dirname(path.resolve(opts.output)), { recursive: true });
124
+ fs.writeFileSync(path.resolve(opts.output), hcl, "utf8");
125
+ console.log(OK(` ✓ Written to ${opts.output}\n`));
126
+ } else {
127
+ console.log(hcl);
128
+ }
129
+ }
130
+
131
+ function generateVmTerraform(vm, { rg, rgLocation, nic, pip, nsg, nsgRules, vnet, subnet, adeKeyVault, state }) {
132
+ const tfName = (vm.name || "vm").replace(/[^a-zA-Z0-9_]/g, "_");
133
+ const adminUser = vm.osProfile?.adminUsername || DEFAULTS.adminUser;
134
+ const vmSize = vm.hardwareProfile?.vmSize || DEFAULTS.vmSize;
135
+ const osDisk = vm.storageProfile?.osDisk || {};
136
+ const imageRef = vm.storageProfile?.imageReference || {};
137
+ const secProfile = vm.securityProfile || {};
138
+ const isTrustedLaunch = secProfile.securityType === "TrustedLaunch";
139
+ const linuxConfig = vm.osProfile?.linuxConfiguration || {};
140
+
141
+ const lines = [];
142
+ const w = (s = "") => lines.push(s);
143
+
144
+ // ── variables ──────────────────────────────────────────────────────────────
145
+
146
+ w(`# ─── Variables ────────────────────────────────────────────────────────────────`);
147
+ w();
148
+ w(`variable "resource_group_name" {`);
149
+ w(` description = "Name of the Azure resource group"`);
150
+ w(` type = string`);
151
+ w(` default = "${rg}"`);
152
+ w(`}`);
153
+ w();
154
+ w(`variable "location" {`);
155
+ w(` description = "Azure region"`);
156
+ w(` type = string`);
157
+ w(` default = "${rgLocation}"`);
158
+ w(`}`);
159
+ w();
160
+ w(`variable "vm_name" {`);
161
+ w(` description = "Name of the VM"`);
162
+ w(` type = string`);
163
+ w(` default = "${vm.name}"`);
164
+ w(`}`);
165
+ w();
166
+ w(`variable "vm_size" {`);
167
+ w(` description = "Azure VM size"`);
168
+ w(` type = string`);
169
+ w(` default = "${vmSize}"`);
170
+ w(`}`);
171
+ w();
172
+ w(`variable "admin_username" {`);
173
+ w(` description = "SSH admin username"`);
174
+ w(` type = string`);
175
+ w(` default = "${adminUser}"`);
176
+ w(`}`);
177
+ w();
178
+ w(`variable "ssh_public_key_path" {`);
179
+ w(` description = "Path to SSH public key file"`);
180
+ w(` type = string`);
181
+ w(` default = "~/.ssh/id_rsa.pub"`);
182
+ w(`}`);
183
+ w();
184
+ w(`variable "os_disk_size_gb" {`);
185
+ w(` description = "OS disk size in GB"`);
186
+ w(` type = number`);
187
+ w(` default = ${osDisk.diskSizeGb || 128}`);
188
+ w(`}`);
189
+ w();
190
+ if (state?.publicUrl) {
191
+ w(`variable "public_url" {`);
192
+ w(` description = "Public URL for the Foundation deployment"`);
193
+ w(` type = string`);
194
+ w(` default = "${state.publicUrl}"`);
195
+ w(`}`);
196
+ w();
197
+ }
198
+
199
+ // ── provider ───────────────────────────────────────────────────────────────
200
+
201
+ w(`# ─── Provider ─────────────────────────────────────────────────────────────────`);
202
+ w();
203
+ w(`terraform {`);
204
+ w(` required_providers {`);
205
+ w(` azurerm = {`);
206
+ w(` source = "hashicorp/azurerm"`);
207
+ w(` version = "~> 4.0"`);
208
+ w(` }`);
209
+ w(` }`);
210
+ w(`}`);
211
+ w();
212
+ w(`provider "azurerm" {`);
213
+ w(` features {}`);
214
+ w(`}`);
215
+ w();
216
+
217
+ // ── resource group ─────────────────────────────────────────────────────────
218
+
219
+ w(`# ─── Resource Group ───────────────────────────────────────────────────────────`);
220
+ w();
221
+ w(`resource "azurerm_resource_group" "vm" {`);
222
+ w(` name = var.resource_group_name`);
223
+ w(` location = var.location`);
224
+ w(`}`);
225
+ w();
226
+
227
+ // ── VNet / Subnet ──────────────────────────────────────────────────────────
228
+
229
+ if (vnet) {
230
+ const vnetTf = (vnet.name || "vnet").replace(/[^a-zA-Z0-9_]/g, "_");
231
+ w(`# ─── Virtual Network ──────────────────────────────────────────────────────────`);
232
+ w();
233
+ w(`resource "azurerm_virtual_network" "${vnetTf}" {`);
234
+ w(` name = "${vnet.name}"`);
235
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
236
+ w(` location = azurerm_resource_group.vm.location`);
237
+ w(` address_space = ${JSON.stringify(vnet.addressSpace?.addressPrefixes || ["10.0.0.0/16"])}`);
238
+ w(`}`);
239
+ w();
240
+
241
+ if (subnet) {
242
+ const subnetTf = (subnet.name || "subnet").replace(/[^a-zA-Z0-9_]/g, "_");
243
+ w(`resource "azurerm_subnet" "${subnetTf}" {`);
244
+ w(` name = "${subnet.name}"`);
245
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
246
+ w(` virtual_network_name = azurerm_virtual_network.${vnetTf}.name`);
247
+ w(` address_prefixes = ${JSON.stringify(subnet.addressPrefix ? [subnet.addressPrefix] : subnet.addressPrefixes || ["10.0.1.0/24"])}`);
248
+ w(`}`);
249
+ w();
250
+ }
251
+ }
252
+
253
+ // ── NSG ────────────────────────────────────────────────────────────────────
254
+
255
+ if (nsg) {
256
+ const nsgTf = (nsg.name || "nsg").replace(/[^a-zA-Z0-9_]/g, "_");
257
+ w(`# ─── Network Security Group ───────────────────────────────────────────────────`);
258
+ w();
259
+ w(`resource "azurerm_network_security_group" "${nsgTf}" {`);
260
+ w(` name = "${nsg.name}"`);
261
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
262
+ w(` location = azurerm_resource_group.vm.location`);
263
+ w(`}`);
264
+ w();
265
+
266
+ for (const rule of nsgRules) {
267
+ const ruleTf = (rule.name || "rule").replace(/[^a-zA-Z0-9_]/g, "_");
268
+ w(`resource "azurerm_network_security_rule" "${ruleTf}" {`);
269
+ w(` name = "${rule.name}"`);
270
+ w(` priority = ${rule.priority}`);
271
+ w(` direction = "${rule.direction}"`);
272
+ w(` access = "${rule.access}"`);
273
+ w(` protocol = "${rule.protocol}"`);
274
+ w(` source_port_range = "${rule.sourcePortRange || "*"}"`);
275
+ w(` destination_port_range = "${rule.destinationPortRange || "*"}"`);
276
+ w(` source_address_prefix = "${rule.sourceAddressPrefix || "*"}"`);
277
+ w(` destination_address_prefix = "${rule.destinationAddressPrefix || "*"}"`);
278
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
279
+ w(` network_security_group_name = azurerm_network_security_group.${nsgTf}.name`);
280
+ w(`}`);
281
+ w();
282
+ }
283
+ }
284
+
285
+ // ── Public IP ──────────────────────────────────────────────────────────────
286
+
287
+ if (pip) {
288
+ const pipTf = (pip.name || "pip").replace(/[^a-zA-Z0-9_]/g, "_");
289
+ w(`# ─── Public IP ────────────────────────────────────────────────────────────────`);
290
+ w();
291
+ w(`resource "azurerm_public_ip" "${pipTf}" {`);
292
+ w(` name = "${pip.name}"`);
293
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
294
+ w(` location = azurerm_resource_group.vm.location`);
295
+ w(` allocation_method = "${pip.publicIPAllocationMethod || "Static"}"`);
296
+ w(` sku = "${pip.sku?.name || "Standard"}"`);
297
+ if (pip.dnsSettings?.domainNameLabel) {
298
+ w(` domain_name_label = "${pip.dnsSettings.domainNameLabel}"`);
299
+ }
300
+ w(`}`);
301
+ w();
302
+ }
303
+
304
+ // ── NIC ────────────────────────────────────────────────────────────────────
305
+
306
+ if (nic) {
307
+ const nicTf = (nic.name || "nic").replace(/[^a-zA-Z0-9_]/g, "_");
308
+ const vnetTf = vnet ? (vnet.name || "vnet").replace(/[^a-zA-Z0-9_]/g, "_") : null;
309
+ const subnetTf = subnet ? (subnet.name || "subnet").replace(/[^a-zA-Z0-9_]/g, "_") : null;
310
+ const nsgTf = nsg ? (nsg.name || "nsg").replace(/[^a-zA-Z0-9_]/g, "_") : null;
311
+ const pipTf = pip ? (pip.name || "pip").replace(/[^a-zA-Z0-9_]/g, "_") : null;
312
+
313
+ w(`# ─── Network Interface ────────────────────────────────────────────────────────`);
314
+ w();
315
+ w(`resource "azurerm_network_interface" "${nicTf}" {`);
316
+ w(` name = "${nic.name}"`);
317
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
318
+ w(` location = azurerm_resource_group.vm.location`);
319
+ if (nic.enableAcceleratedNetworking) {
320
+ w(` accelerated_networking_enabled = true`);
321
+ }
322
+ w();
323
+ w(` ip_configuration {`);
324
+ w(` name = "${nic.ipConfigurations?.[0]?.name || "ipconfig1"}"`);
325
+ if (subnetTf) {
326
+ w(` subnet_id = azurerm_subnet.${subnetTf}.id`);
327
+ }
328
+ w(` private_ip_address_allocation = "${nic.ipConfigurations?.[0]?.privateIPAllocationMethod || "Dynamic"}"`);
329
+ if (pipTf) {
330
+ w(` public_ip_address_id = azurerm_public_ip.${pipTf}.id`);
331
+ }
332
+ w(` }`);
333
+ w(`}`);
334
+ w();
335
+
336
+ if (nsgTf) {
337
+ w(`resource "azurerm_network_interface_security_group_association" "${nicTf}_nsg" {`);
338
+ w(` network_interface_id = azurerm_network_interface.${nicTf}.id`);
339
+ w(` network_security_group_id = azurerm_network_security_group.${nsgTf}.id`);
340
+ w(`}`);
341
+ w();
342
+ }
343
+ }
344
+
345
+ // ── ADE Key Vault ──────────────────────────────────────────────────────────
346
+
347
+ if (adeKeyVault) {
348
+ const kvTf = (adeKeyVault.name || "ade_kv").replace(/[^a-zA-Z0-9_]/g, "_");
349
+ w(`# ─── Disk Encryption Key Vault ────────────────────────────────────────────────`);
350
+ w();
351
+ w(`resource "azurerm_key_vault" "${kvTf}" {`);
352
+ w(` name = "${adeKeyVault.name}"`);
353
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
354
+ w(` location = azurerm_resource_group.vm.location`);
355
+ w(` tenant_id = "${adeKeyVault.properties?.tenantId || ""}"`);
356
+ w(` sku_name = "${adeKeyVault.properties?.sku?.name || "standard"}"`);
357
+ w(` enabled_for_disk_encryption = true`);
358
+ if (adeKeyVault.properties?.enableRbacAuthorization) {
359
+ w(` enable_rbac_authorization = true`);
360
+ }
361
+ if (adeKeyVault.properties?.softDeleteRetentionInDays) {
362
+ w(` soft_delete_retention_days = ${adeKeyVault.properties.softDeleteRetentionInDays}`);
363
+ }
364
+ if (adeKeyVault.properties?.enablePurgeProtection) {
365
+ w(` purge_protection_enabled = true`);
366
+ }
367
+ w(`}`);
368
+ w();
369
+ }
370
+
371
+ // ── Virtual Machine ────────────────────────────────────────────────────────
372
+
373
+ const nicTf = nic ? (nic.name || "nic").replace(/[^a-zA-Z0-9_]/g, "_") : null;
374
+
375
+ w(`# ─── Virtual Machine ─────────────────────────────────────────────────────────`);
376
+ w();
377
+ w(`resource "azurerm_linux_virtual_machine" "${tfName}" {`);
378
+ w(` name = var.vm_name`);
379
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
380
+ w(` location = azurerm_resource_group.vm.location`);
381
+ w(` size = var.vm_size`);
382
+ w(` admin_username = var.admin_username`);
383
+ if (linuxConfig.disablePasswordAuthentication !== false) {
384
+ w(` disable_password_authentication = true`);
385
+ }
386
+ if (nicTf) {
387
+ w(` network_interface_ids = [azurerm_network_interface.${nicTf}.id]`);
388
+ }
389
+ w();
390
+
391
+ w(` admin_ssh_key {`);
392
+ w(` username = var.admin_username`);
393
+ w(` public_key = file(var.ssh_public_key_path)`);
394
+ w(` }`);
395
+ w();
396
+
397
+ w(` os_disk {`);
398
+ w(` name = "${osDisk.name || vm.name + "-osdisk"}"`);
399
+ w(` caching = "${osDisk.caching || "ReadWrite"}"`);
400
+ w(` storage_account_type = "${osDisk.managedDisk?.storageAccountType || "Premium_LRS"}"`);
401
+ w(` disk_size_gb = var.os_disk_size_gb`);
402
+ w(` }`);
403
+ w();
404
+
405
+ if (imageRef.publisher) {
406
+ w(` source_image_reference {`);
407
+ w(` publisher = "${imageRef.publisher}"`);
408
+ w(` offer = "${imageRef.offer}"`);
409
+ w(` sku = "${imageRef.sku}"`);
410
+ w(` version = "${imageRef.exactVersion || imageRef.version || "latest"}"`);
411
+ w(` }`);
412
+ w();
413
+ } else if (imageRef.id) {
414
+ w(` source_image_id = "${imageRef.id}"`);
415
+ w();
416
+ }
417
+
418
+ if (isTrustedLaunch) {
419
+ w(` secure_boot_enabled = ${secProfile.uefiSettings?.secureBootEnabled ?? true}`);
420
+ w(` vtpm_enabled = ${secProfile.uefiSettings?.vTpmEnabled ?? true}`);
421
+ w();
422
+ }
423
+
424
+ if (vm.diagnosticsProfile?.bootDiagnostics?.enabled) {
425
+ w(` boot_diagnostics {}`);
426
+ w();
427
+ }
428
+
429
+ if (vm.identity?.type && vm.identity.type !== "None") {
430
+ w(` identity {`);
431
+ w(` type = "${vm.identity.type}"`);
432
+ w(` }`);
433
+ w();
434
+ }
435
+
436
+ const patchMode = linuxConfig.patchSettings?.patchMode;
437
+ if (patchMode) {
438
+ w(` patch_mode = "${patchMode}"`);
439
+ if (patchMode === "AutomaticByPlatform") {
440
+ w(` patch_assessment_mode = "AutomaticByPlatform"`);
441
+ }
442
+ w();
443
+ }
444
+
445
+ w(` # Foundation provisioning script — uncomment and point to your cloud-init`);
446
+ w(` # custom_data = filebase64("cloud-init-foundation.sh")`);
447
+ w();
448
+
449
+ const tags = vm.tags || {};
450
+ const tagEntries = Object.entries(tags);
451
+ if (tagEntries.length) {
452
+ w(` tags = {`);
453
+ for (const [k, v] of tagEntries) {
454
+ w(` ${JSON.stringify(k)} = ${JSON.stringify(v)}`);
455
+ }
456
+ w(` }`);
457
+ w();
458
+ }
459
+
460
+ w(`}`);
461
+ w();
462
+
463
+ // ── Data disks ─────────────────────────────────────────────────────────────
464
+
465
+ for (const dd of vm.storageProfile?.dataDisks || []) {
466
+ const ddTf = (dd.name || `datadisk_lun${dd.lun}`).replace(/[^a-zA-Z0-9_]/g, "_");
467
+ w(`resource "azurerm_managed_disk" "${ddTf}" {`);
468
+ w(` name = "${dd.name}"`);
469
+ w(` resource_group_name = azurerm_resource_group.vm.name`);
470
+ w(` location = azurerm_resource_group.vm.location`);
471
+ w(` storage_account_type = "${dd.managedDisk?.storageAccountType || "Premium_LRS"}"`);
472
+ w(` create_option = "Empty"`);
473
+ w(` disk_size_gb = ${dd.diskSizeGb || 128}`);
474
+ w(`}`);
475
+ w();
476
+ w(`resource "azurerm_virtual_machine_data_disk_attachment" "${ddTf}" {`);
477
+ w(` managed_disk_id = azurerm_managed_disk.${ddTf}.id`);
478
+ w(` virtual_machine_id = azurerm_linux_virtual_machine.${tfName}.id`);
479
+ w(` lun = ${dd.lun}`);
480
+ w(` caching = "${dd.caching || "ReadWrite"}"`);
481
+ w(`}`);
482
+ w();
483
+ }
484
+
485
+ // ── Resource lock ──────────────────────────────────────────────────────────
486
+
487
+ w(`resource "azurerm_management_lock" "${tfName}_lock" {`);
488
+ w(` name = "${vm.name}-lock"`);
489
+ w(` scope = azurerm_linux_virtual_machine.${tfName}.id`);
490
+ w(` lock_level = "CanNotDelete"`);
491
+ w(` notes = "Managed by fops — prevents accidental deletion"`);
492
+ w(`}`);
493
+ w();
494
+
495
+ // ── VM extension: ADE ──────────────────────────────────────────────────────
496
+
497
+ const adeExt = (vm.resources || []).find(r => r.id?.toLowerCase().includes("azurediskencryption"));
498
+ if (adeExt && adeKeyVault) {
499
+ const kvTf = (adeKeyVault.name || "ade_kv").replace(/[^a-zA-Z0-9_]/g, "_");
500
+ w(`resource "azurerm_virtual_machine_extension" "ade" {`);
501
+ w(` name = "AzureDiskEncryption"`);
502
+ w(` virtual_machine_id = azurerm_linux_virtual_machine.${tfName}.id`);
503
+ w(` publisher = "Microsoft.Azure.Security"`);
504
+ w(` type = "AzureDiskEncryptionForLinux"`);
505
+ w(` type_handler_version = "1.4"`);
506
+ w();
507
+ w(` settings = jsonencode({`);
508
+ w(` EncryptionOperation = "EnableEncryption"`);
509
+ w(` KeyVaultURL = azurerm_key_vault.${kvTf}.vault_uri`);
510
+ w(` KeyVaultResourceId = azurerm_key_vault.${kvTf}.id`);
511
+ w(` VolumeType = "All"`);
512
+ w(` })`);
513
+ w(`}`);
514
+ w();
515
+ }
516
+
517
+ // ── outputs ────────────────────────────────────────────────────────────────
518
+
519
+ w(`# ─── Outputs ─────────────────────────────────────────────────────────────────`);
520
+ w();
521
+ w(`output "vm_name" {`);
522
+ w(` value = azurerm_linux_virtual_machine.${tfName}.name`);
523
+ w(`}`);
524
+ w();
525
+ if (pip) {
526
+ const pipTf = (pip.name || "pip").replace(/[^a-zA-Z0-9_]/g, "_");
527
+ w(`output "public_ip" {`);
528
+ w(` value = azurerm_public_ip.${pipTf}.ip_address`);
529
+ w(`}`);
530
+ w();
531
+ }
532
+ w(`output "admin_username" {`);
533
+ w(` value = var.admin_username`);
534
+ w(`}`);
535
+ w();
536
+ if (state?.publicUrl) {
537
+ w(`output "public_url" {`);
538
+ w(` value = var.public_url`);
539
+ w(`}`);
540
+ w();
541
+ }
542
+
543
+ return lines.join("\n");
544
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import chalk from "chalk";
5
5
  import { resolveRemoteAuth, suppressTlsWarning } from "../azure-auth.js";
6
- import { parsePytestSummary, parsePytestDurations } from "../pytest-parse.js";
6
+ import { parsePytestSummary, parsePytestDurations, parsePytestFailedTests } from "../pytest-parse.js";
7
7
 
8
8
  export function registerInfraCommands(azure) {
9
9
  // ── Storage (blob management) ──────────────────────────────────────────
@@ -573,6 +573,7 @@ export function registerInfraCommands(azure) {
573
573
  .option("--no-zones", "Disable availability zone redundancy")
574
574
  .option("--geo-replica", "Create Postgres geo-replica for DR (default when in UAE)")
575
575
  .option("--no-geo-replica", "Skip Postgres geo-replica creation")
576
+ .option("--ha", "Enable full HA: storage replication + Postgres replica + standby AKS in North Europe")
576
577
  .option("--reprovision", "Re-render and push cluster template (for existing clusters)")
577
578
  .action(async (name, opts) => {
578
579
  const { aksUp } = await import("../azure-aks.js");
@@ -599,6 +600,7 @@ export function registerInfraCommands(azure) {
599
600
  dai: opts.dai === true,
600
601
  zones: opts.zones,
601
602
  geoReplica: opts.geoReplica,
603
+ ha: opts.ha === true,
602
604
  reprovision: opts.reprovision === true,
603
605
  });
604
606
  });
@@ -748,6 +750,44 @@ export function registerInfraCommands(azure) {
748
750
  envContent = setVar(envContent, "BEARER_TOKEN", bearerToken);
749
751
  envContent = setVar(envContent, "TOKEN_AUTH0", bearerToken);
750
752
  }
753
+
754
+ // Resolve CF Access credentials from ~/.fops.json or env
755
+ let cfAccessClientId = process.env.CF_ACCESS_CLIENT_ID || "";
756
+ let cfAccessClientSecret = process.env.CF_ACCESS_CLIENT_SECRET || "";
757
+ if (!cfAccessClientId) {
758
+ try {
759
+ const os = await import("node:os");
760
+ const fopsJson = JSON.parse(await fsp.readFile(path.join(os.homedir(), ".fops.json"), "utf8"));
761
+ cfAccessClientId = fopsJson?.cloudflare?.accessClientId || "";
762
+ cfAccessClientSecret = fopsJson?.cloudflare?.accessClientSecret || "";
763
+ } catch { /* no fops.json */ }
764
+ }
765
+ if (cfAccessClientId) {
766
+ envContent = setVar(envContent, "CF_ACCESS_CLIENT_ID", cfAccessClientId);
767
+ envContent = setVar(envContent, "CF_ACCESS_CLIENT_SECRET", cfAccessClientSecret);
768
+ console.log(chalk.green(" ✓ Found CF Access credentials"));
769
+ }
770
+
771
+ // Fetch S3 credentials from cluster secret
772
+ let s3AccessKey = "";
773
+ let s3SecretKey = "";
774
+ try {
775
+ const { stdout } = await execaFn("kubectl", [
776
+ "get", "secret", "-n", "foundation", "storage-engine-auth-credentials",
777
+ "-o", "jsonpath={.data.AUTH_IDENTITY},{.data.AUTH_CREDENTIAL}",
778
+ ], { timeout: 15000 });
779
+ const [identityB64, credentialB64] = stdout.trim().split(",");
780
+ if (identityB64 && credentialB64) {
781
+ s3AccessKey = Buffer.from(identityB64, "base64").toString("utf8");
782
+ s3SecretKey = Buffer.from(credentialB64, "base64").toString("utf8");
783
+ envContent = setVar(envContent, "S3_ACCESS_KEY", s3AccessKey);
784
+ envContent = setVar(envContent, "S3_SECRET_KEY", s3SecretKey);
785
+ console.log(chalk.green(" ✓ Retrieved S3 credentials from cluster"));
786
+ }
787
+ } catch {
788
+ console.log(chalk.dim(" ⚠ Could not fetch S3 credentials from cluster"));
789
+ }
790
+
751
791
  await fsp.writeFile(envPath, envContent);
752
792
  console.log(chalk.green(` ✓ Configured QA .env → ${apiUrl}`));
753
793
 
@@ -803,6 +843,14 @@ export function registerInfraCommands(azure) {
803
843
  testEnv.BEARER_TOKEN = bearerToken;
804
844
  testEnv.TOKEN_AUTH0 = bearerToken;
805
845
  }
846
+ if (cfAccessClientId) {
847
+ testEnv.CF_ACCESS_CLIENT_ID = cfAccessClientId;
848
+ testEnv.CF_ACCESS_CLIENT_SECRET = cfAccessClientSecret;
849
+ }
850
+ if (s3AccessKey) {
851
+ testEnv.S3_ACCESS_KEY = s3AccessKey;
852
+ testEnv.S3_SECRET_KEY = s3SecretKey;
853
+ }
806
854
 
807
855
  const aksStartMs = Date.now();
808
856
  const aksProc = execaFn(
@@ -818,6 +866,7 @@ export function registerInfraCommands(azure) {
818
866
 
819
867
  const aksCounts = parsePytestSummary(aksCaptured);
820
868
  const aksTiming = parsePytestDurations(aksCaptured);
869
+ const aksFailedTests = parsePytestFailedTests(aksCaptured);
821
870
  const qaResult = {
822
871
  passed: exitCode === 0,
823
872
  exitCode,
@@ -829,14 +878,35 @@ export function registerInfraCommands(azure) {
829
878
  ...(aksCounts.skipped != null && { numSkipped: aksCounts.skipped }),
830
879
  durationSec: aksCounts.durationSec || aksWallSec,
831
880
  ...(aksTiming && { timing: aksTiming }),
881
+ ...(aksFailedTests.length > 0 && { failedTests: aksFailedTests.slice(0, 50) }),
832
882
  };
833
883
  writeClusterState(cluster.clusterName, { qa: qaResult });
834
884
 
835
885
  if (exitCode === 0) {
836
886
  console.log(chalk.green("\n ✓ QA tests passed\n"));
837
887
  } else {
838
- console.error(chalk.red(`\n QA tests failed (exit ${exitCode}).`));
839
- console.error(chalk.dim(` Report: ${path.join(qaDir, "playwright-report", "report.html")}\n`));
888
+ console.error(chalk.red(`\n QA tests failed (exit ${exitCode})`));
889
+
890
+ // Parse and display failed tests summary
891
+ const failedTests = parsePytestFailedTests(aksCaptured);
892
+ if (failedTests.length > 0) {
893
+ console.error(chalk.red(`\n Failed tests (${failedTests.length}):`));
894
+ for (const { test, reason } of failedTests.slice(0, 20)) {
895
+ const shortTest = test.replace(/^tests\//, "");
896
+ const reasonStr = reason ? chalk.dim(` - ${reason.slice(0, 60)}`) : "";
897
+ console.error(chalk.red(` • ${shortTest}${reasonStr}`));
898
+ }
899
+ if (failedTests.length > 20) {
900
+ console.error(chalk.dim(` ... and ${failedTests.length - 20} more`));
901
+ }
902
+ }
903
+
904
+ // Show summary counts
905
+ if (aksCounts.passed != null || aksCounts.failed != null) {
906
+ console.error(chalk.dim(`\n Summary: ${aksCounts.passed || 0} passed, ${aksCounts.failed || 0} failed, ${aksCounts.skipped || 0} skipped`));
907
+ }
908
+
909
+ console.error(chalk.dim(`\n Report: ${path.join(qaDir, "playwright-report", "report.html")}\n`));
840
910
  process.exitCode = 1;
841
911
  }
842
912
 
@@ -874,6 +944,7 @@ export function registerInfraCommands(azure) {
874
944
  .option("--bearer-token <token>", "Bearer token for API authentication")
875
945
  .option("--yes", "Use credentials from env or ~/.fops.json, skip prompt")
876
946
  .option("--profile <subscription>", "Azure subscription name or ID")
947
+ .option("--skip-sample-upload", "Skip uploading sample CSV files to MinIO storage")
877
948
  .action(async (clusterName, opts) => {
878
949
  const { aksDataBootstrap } = await import("../azure-aks.js");
879
950
  await aksDataBootstrap({
@@ -882,6 +953,7 @@ export function registerInfraCommands(azure) {
882
953
  apiUrl: opts.apiUrl,
883
954
  bearerToken: opts.bearerToken,
884
955
  yes: opts.yes,
956
+ skipSampleUpload: opts.skipSampleUpload,
885
957
  });
886
958
  });
887
959