@meshxdata/fops 0.1.36 → 0.1.37

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.
@@ -0,0 +1,890 @@
1
+ /**
2
+ * Infrastructure commands: storage, openai, keyvault, AKS, embed index.
3
+ */
4
+ import chalk from "chalk";
5
+ import { resolveRemoteAuth, suppressTlsWarning } from "../azure-auth.js";
6
+ import { parsePytestSummary, parsePytestDurations } from "../pytest-parse.js";
7
+
8
+ export function registerInfraCommands(azure) {
9
+ // ── Storage (blob management) ──────────────────────────────────────────
10
+
11
+ const storage = azure
12
+ .command("storage")
13
+ .description("Manage Azure Blob Storage accounts, containers, and blobs");
14
+
15
+ storage
16
+ .command("accounts")
17
+ .description("List storage accounts")
18
+ .option("--profile <subscription>", "Azure subscription name or ID")
19
+ .action(async (opts) => {
20
+ const { storageList } = await import("../azure-storage.js");
21
+ await storageList({ profile: opts.profile });
22
+ });
23
+
24
+ storage
25
+ .command("create <name>")
26
+ .description("Create a new storage account")
27
+ .option("--resource-group <name>", "Resource group (lists available if omitted)")
28
+ .requiredOption("--location <region>", "Azure region (e.g. eastus, westeurope, uaenorth)")
29
+ .option("--sku <sku>", "Replication SKU (default: Standard_LRS)", "Standard_LRS")
30
+ .option("--kind <kind>", "Account kind (default: StorageV2)", "StorageV2")
31
+ .option("--infra-encryption", "Enable infrastructure (double) encryption")
32
+ .option("--tags <tags>", "Space-separated tags: key1=val1 key2=val2")
33
+ .option("--profile <subscription>", "Azure subscription name or ID")
34
+ .action(async (name, opts) => {
35
+ const { storageCreate } = await import("../azure-storage.js");
36
+ await storageCreate({
37
+ name, resourceGroup: opts.resourceGroup, location: opts.location,
38
+ sku: opts.sku, kind: opts.kind, infraEncryption: opts.infraEncryption,
39
+ tags: opts.tags, profile: opts.profile,
40
+ });
41
+ });
42
+
43
+ const containers = storage
44
+ .command("container")
45
+ .description("Manage blob containers");
46
+
47
+ containers
48
+ .command("list")
49
+ .description("List containers in a storage account")
50
+ .option("--account <name>", "Storage account (auto-detected if only one)")
51
+ .option("--profile <subscription>", "Azure subscription name or ID")
52
+ .action(async (opts) => {
53
+ const { containerList } = await import("../azure-storage.js");
54
+ await containerList({ account: opts.account, profile: opts.profile });
55
+ });
56
+
57
+ containers
58
+ .command("create <name>")
59
+ .description("Create a new container")
60
+ .option("--account <name>", "Storage account (auto-detected if only one)")
61
+ .option("--profile <subscription>", "Azure subscription name or ID")
62
+ .action(async (name, opts) => {
63
+ const { containerCreate } = await import("../azure-storage.js");
64
+ await containerCreate({ account: opts.account, name, profile: opts.profile });
65
+ });
66
+
67
+ containers
68
+ .command("delete <name>")
69
+ .description("Delete a container (asks for confirmation)")
70
+ .option("--account <name>", "Storage account (auto-detected if only one)")
71
+ .option("--profile <subscription>", "Azure subscription name or ID")
72
+ .action(async (name, opts) => {
73
+ const { containerDelete } = await import("../azure-storage.js");
74
+ await containerDelete({ account: opts.account, name, profile: opts.profile });
75
+ });
76
+
77
+ const blob = storage
78
+ .command("blob")
79
+ .description("Manage blobs in a container");
80
+
81
+ blob
82
+ .command("list")
83
+ .description("List blobs in a container")
84
+ .option("--account <name>", "Storage account (auto-detected if only one)")
85
+ .requiredOption("--container <name>", "Container name")
86
+ .option("--prefix <prefix>", "Filter by blob name prefix")
87
+ .option("--profile <subscription>", "Azure subscription name or ID")
88
+ .action(async (opts) => {
89
+ const { blobList } = await import("../azure-storage.js");
90
+ await blobList({ account: opts.account, container: opts.container, prefix: opts.prefix, profile: opts.profile });
91
+ });
92
+
93
+ blob
94
+ .command("upload <file>")
95
+ .description("Upload a file as a blob")
96
+ .option("--account <name>", "Storage account (auto-detected if only one)")
97
+ .requiredOption("--container <name>", "Container name")
98
+ .option("--name <blob-name>", "Blob name (default: filename)")
99
+ .option("--overwrite", "Overwrite existing blob")
100
+ .option("--profile <subscription>", "Azure subscription name or ID")
101
+ .action(async (file, opts) => {
102
+ const { blobUpload } = await import("../azure-storage.js");
103
+ await blobUpload({ account: opts.account, container: opts.container, file, name: opts.name, overwrite: opts.overwrite, profile: opts.profile });
104
+ });
105
+
106
+ blob
107
+ .command("download <name>")
108
+ .description("Download a blob to a local file")
109
+ .option("--account <name>", "Storage account (auto-detected if only one)")
110
+ .requiredOption("--container <name>", "Container name")
111
+ .option("--dest <path>", "Local destination path (default: blob filename)")
112
+ .option("--profile <subscription>", "Azure subscription name or ID")
113
+ .action(async (name, opts) => {
114
+ const { blobDownload } = await import("../azure-storage.js");
115
+ await blobDownload({ account: opts.account, container: opts.container, name, dest: opts.dest, profile: opts.profile });
116
+ });
117
+
118
+ blob
119
+ .command("delete <name>")
120
+ .description("Delete a blob (asks for confirmation)")
121
+ .option("--account <name>", "Storage account (auto-detected if only one)")
122
+ .requiredOption("--container <name>", "Container name")
123
+ .option("--profile <subscription>", "Azure subscription name or ID")
124
+ .action(async (name, opts) => {
125
+ const { blobDelete } = await import("../azure-storage.js");
126
+ await blobDelete({ account: opts.account, container: opts.container, name, profile: opts.profile });
127
+ });
128
+
129
+ blob
130
+ .command("url <name>")
131
+ .description("Generate a temporary SAS URL for a blob")
132
+ .option("--account <name>", "Storage account (auto-detected if only one)")
133
+ .requiredOption("--container <name>", "Container name")
134
+ .option("--expiry <hours>", "Link expiry in hours (default: 24)", "24")
135
+ .option("--profile <subscription>", "Azure subscription name or ID")
136
+ .action(async (name, opts) => {
137
+ const { blobUrl } = await import("../azure-storage.js");
138
+ await blobUrl({ account: opts.account, container: opts.container, name, expiry: opts.expiry, profile: opts.profile });
139
+ });
140
+
141
+ const encryption = storage
142
+ .command("encryption")
143
+ .description("Show or configure storage account encryption settings");
144
+
145
+ encryption
146
+ .command("show", { isDefault: true })
147
+ .description("Show encryption posture for a storage account")
148
+ .option("--account <name>", "Storage account (auto-detected if only one)")
149
+ .option("--profile <subscription>", "Azure subscription name or ID")
150
+ .action(async (opts) => {
151
+ const { storageEncryption } = await import("../azure-storage.js");
152
+ await storageEncryption({ account: opts.account, profile: opts.profile });
153
+ });
154
+
155
+ encryption
156
+ .command("configure")
157
+ .description("Configure encryption and security settings")
158
+ .option("--account <name>", "Storage account (auto-detected if only one)")
159
+ .option("--profile <subscription>", "Azure subscription name or ID")
160
+ .option("--https-only", "Enforce HTTPS-only traffic")
161
+ .option("--min-tls <version>", "Minimum TLS version (TLS1_0, TLS1_1, TLS1_2)")
162
+ .option("--disable-shared-key", "Disable shared key auth (force AAD-only)")
163
+ .option("--enable-shared-key", "Re-enable shared key auth")
164
+ .option("--cmk", "Use customer-managed key (requires --key-vault, --key-name)")
165
+ .option("--key-vault <uri>", "Key Vault URI for CMK")
166
+ .option("--key-name <name>", "Key name in Key Vault")
167
+ .option("--key-version <version>", "Key version (default: latest)")
168
+ .option("--microsoft-keys", "Revert to Microsoft-managed keys")
169
+ .option("--infra-encryption", "Check/recommend infrastructure (double) encryption")
170
+ .option("--soft-delete <days>", "Enable blob soft delete (0 to disable)")
171
+ .option("--versioning", "Enable blob versioning")
172
+ .option("--no-versioning", "Disable blob versioning")
173
+ .option("--disable-blob-public", "Block anonymous read access on all containers")
174
+ .option("--deny-public-access", "Firewall: deny all networks except allowed IPs/VNets")
175
+ .option("--allow-public-access", "Firewall: allow all networks (remove restriction)")
176
+ .action(async (opts) => {
177
+ const { storageEncryptionConfigure } = await import("../azure-storage.js");
178
+ await storageEncryptionConfigure({
179
+ account: opts.account, profile: opts.profile,
180
+ httpsOnly: opts.httpsOnly, minTls: opts.minTls,
181
+ disableSharedKey: opts.disableSharedKey, enableSharedKey: opts.enableSharedKey,
182
+ cmk: opts.cmk, keyVault: opts.keyVault, keyName: opts.keyName, keyVersion: opts.keyVersion,
183
+ microsoftKeys: opts.microsoftKeys, infraEncryption: opts.infraEncryption,
184
+ softDelete: opts.softDelete, versioning: opts.versioning,
185
+ disableBlobPublic: opts.disableBlobPublic,
186
+ denyPublicAccess: opts.denyPublicAccess, allowPublicAccess: opts.allowPublicAccess,
187
+ });
188
+ });
189
+
190
+ // ── Azure OpenAI ───────────────────────────────────────────────────────
191
+
192
+ const openai = azure
193
+ .command("openai")
194
+ .description("Create/list Azure OpenAI resources (e.g. in uaenorth so VMs can use them)");
195
+
196
+ openai
197
+ .command("list", { isDefault: true })
198
+ .description("List Azure OpenAI resources in the subscription")
199
+ .option("--profile <subscription>", "Azure subscription name or ID")
200
+ .action(async (opts) => {
201
+ const { openaiList } = await import("../azure-openai.js");
202
+ await openaiList({ profile: opts.profile });
203
+ });
204
+
205
+ openai
206
+ .command("create <name>")
207
+ .description("Create an Azure OpenAI resource in a region (default: uaenorth)")
208
+ .requiredOption("--resource-group <name>", "Resource group (create with: az group create --name <name> --location uaenorth)")
209
+ .option("--location <region>", "Azure region (default: uaenorth)", "uaenorth")
210
+ .option("--sku <sku>", "SKU (default: S0)", "S0")
211
+ .option("--profile <subscription>", "Azure subscription name or ID")
212
+ .action(async (name, opts) => {
213
+ const { openaiCreate } = await import("../azure-openai.js");
214
+ await openaiCreate({
215
+ name,
216
+ resourceGroup: opts.resourceGroup,
217
+ location: opts.location,
218
+ sku: opts.sku,
219
+ profile: opts.profile,
220
+ });
221
+ });
222
+
223
+ openai
224
+ .command("allowlist-me")
225
+ .description("Add an IP to Azure OpenAI resource allowlists (uses az cli)")
226
+ .option("--profile <subscription>", "Azure subscription name or ID")
227
+ .option("--all", "Allowlist on every OpenAI/AIServices resource across all subscriptions")
228
+ .option("--name <resource>", "Target a specific Azure OpenAI resource by name")
229
+ .option("--resource-group <rg>", "Resource group (auto-detected if omitted)")
230
+ .option("--ip <address>", "IP address to allowlist (auto-detected if omitted)")
231
+ .action(async (opts) => {
232
+ const { openaiAllowlistMe } = await import("../azure-openai.js");
233
+ await openaiAllowlistMe({
234
+ profile: opts.profile,
235
+ all: opts.all,
236
+ name: opts.name,
237
+ resourceGroup: opts.resourceGroup,
238
+ ip: opts.ip,
239
+ });
240
+ });
241
+
242
+ openai
243
+ .command("debug-vm [vmName] [run]")
244
+ .description("Run a single agent request on the VM with DEBUG=1 to capture Azure OpenAI connection errors")
245
+ .option("--vm-name <name>", "Target VM (default: active VM)")
246
+ .option("--agent <name>", "Agent name (default: foundation)", "foundation")
247
+ .option("--profile <subscription>", "Azure subscription name or ID")
248
+ .action(async (vmName, _run, opts) => {
249
+ const name = vmName === "run" ? undefined : (vmName || opts.vmName);
250
+ const { azureOpenAiDebugVm } = await import("../azure.js");
251
+ await azureOpenAiDebugVm({
252
+ vmName: name,
253
+ agent: opts.agent,
254
+ profile: opts.profile,
255
+ });
256
+ });
257
+
258
+ // ── Key Vault ──────────────────────────────────────────────────────────
259
+
260
+ const keyvault = azure
261
+ .command("keyvault")
262
+ .description("Manage Azure Key Vaults and secrets");
263
+
264
+ keyvault
265
+ .command("list", { isDefault: true })
266
+ .description("List Key Vaults in the subscription")
267
+ .option("--profile <subscription>", "Azure subscription name or ID")
268
+ .action(async (opts) => {
269
+ const { keyvaultList } = await import("../azure-keyvault.js");
270
+ await keyvaultList({ profile: opts.profile });
271
+ });
272
+
273
+ keyvault
274
+ .command("show")
275
+ .description("Show Key Vault details and object counts")
276
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
277
+ .option("--profile <subscription>", "Azure subscription name or ID")
278
+ .action(async (opts) => {
279
+ const { keyvaultShow } = await import("../azure-keyvault.js");
280
+ await keyvaultShow({ vault: opts.vault, profile: opts.profile });
281
+ });
282
+
283
+ keyvault
284
+ .command("create <name>")
285
+ .description("Create a new Key Vault")
286
+ .option("--resource-group <name>", "Resource group (lists available if omitted)")
287
+ .requiredOption("--location <region>", "Azure region (e.g. eastus, westeurope, uaenorth)")
288
+ .option("--sku <sku>", "SKU: standard or premium (default: standard)", "standard")
289
+ .option("--enable-purge-protection", "Enable purge protection (irreversible)")
290
+ .option("--retention-days <days>", "Soft-delete retention days (default: 90)")
291
+ .option("--profile <subscription>", "Azure subscription name or ID")
292
+ .action(async (name, opts) => {
293
+ const { keyvaultCreate } = await import("../azure-keyvault.js");
294
+ await keyvaultCreate({
295
+ name, resourceGroup: opts.resourceGroup, location: opts.location,
296
+ sku: opts.sku, enablePurgeProtection: opts.enablePurgeProtection,
297
+ retentionDays: opts.retentionDays, profile: opts.profile,
298
+ });
299
+ });
300
+
301
+ keyvault
302
+ .command("delete")
303
+ .description("Delete a Key Vault (soft-delete)")
304
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
305
+ .option("--purge", "Permanently purge after deletion (requires purge protection off)")
306
+ .option("--profile <subscription>", "Azure subscription name or ID")
307
+ .action(async (opts) => {
308
+ const { keyvaultDelete } = await import("../azure-keyvault.js");
309
+ await keyvaultDelete({ vault: opts.vault, purge: opts.purge, profile: opts.profile });
310
+ });
311
+
312
+ keyvault
313
+ .command("sync")
314
+ .description("Pull secrets from Key Vault into .env files (reads .env.keyvault templates)")
315
+ .option("--vault <name>", "Override default vault for all references")
316
+ .option("--profile <subscription>", "Azure subscription name or ID")
317
+ .action(async (opts) => {
318
+ const { keyvaultSync } = await import("../azure-keyvault.js");
319
+ await keyvaultSync({ vault: opts.vault, profile: opts.profile });
320
+ });
321
+
322
+ keyvault
323
+ .command("setup")
324
+ .description("Interactive setup: pick vault, set auto-sync, scaffold .env.keyvault templates")
325
+ .option("--profile <subscription>", "Azure subscription name or ID")
326
+ .action(async (opts) => {
327
+ const { keyvaultSetup } = await import("../azure-keyvault.js");
328
+ await keyvaultSetup({ profile: opts.profile });
329
+ });
330
+
331
+ keyvault
332
+ .command("status")
333
+ .description("Show Key Vault sync status: auth, config, discovered templates")
334
+ .option("--profile <subscription>", "Azure subscription name or ID")
335
+ .action(async (opts) => {
336
+ const { keyvaultStatus } = await import("../azure-keyvault.js");
337
+ await keyvaultStatus({ profile: opts.profile });
338
+ });
339
+
340
+ const kvSecret = keyvault
341
+ .command("secret")
342
+ .description("Manage secrets in a Key Vault");
343
+
344
+ kvSecret
345
+ .command("list", { isDefault: true })
346
+ .description("List secrets in a Key Vault")
347
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
348
+ .option("--include-managed", "Include managed (certificate-linked) secrets")
349
+ .option("--profile <subscription>", "Azure subscription name or ID")
350
+ .action(async (opts) => {
351
+ const { secretList } = await import("../azure-keyvault.js");
352
+ await secretList({ vault: opts.vault, includeManaged: opts.includeManaged, profile: opts.profile });
353
+ });
354
+
355
+ kvSecret
356
+ .command("show <name>")
357
+ .description("Show secret metadata (add --show-value to reveal)")
358
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
359
+ .option("--version <version>", "Specific secret version (default: latest)")
360
+ .option("--show-value", "Display the secret value")
361
+ .option("--profile <subscription>", "Azure subscription name or ID")
362
+ .action(async (name, opts) => {
363
+ const { secretShow } = await import("../azure-keyvault.js");
364
+ await secretShow({ vault: opts.vault, name, version: opts.version, showValue: opts.showValue, profile: opts.profile });
365
+ });
366
+
367
+ kvSecret
368
+ .command("set <name>")
369
+ .description("Set a secret value (creates or updates)")
370
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
371
+ .option("--value <value>", "Secret value (use --file for binary/large)")
372
+ .option("--file <path>", "Read secret value from a file")
373
+ .option("--content-type <type>", "Content type (e.g. text/plain, application/json)")
374
+ .option("--expires <datetime>", "Expiration date (ISO 8601)")
375
+ .option("--disabled", "Create the secret in disabled state")
376
+ .option("--profile <subscription>", "Azure subscription name or ID")
377
+ .action(async (name, opts) => {
378
+ const { secretSet } = await import("../azure-keyvault.js");
379
+ await secretSet({
380
+ vault: opts.vault, name, value: opts.value, file: opts.file,
381
+ contentType: opts.contentType, expires: opts.expires,
382
+ disabled: opts.disabled, profile: opts.profile,
383
+ });
384
+ });
385
+
386
+ kvSecret
387
+ .command("delete <name>")
388
+ .description("Delete a secret (soft-delete, recoverable)")
389
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
390
+ .option("--profile <subscription>", "Azure subscription name or ID")
391
+ .action(async (name, opts) => {
392
+ const { secretDelete } = await import("../azure-keyvault.js");
393
+ await secretDelete({ vault: opts.vault, name, profile: opts.profile });
394
+ });
395
+
396
+ const kvKey = keyvault
397
+ .command("key")
398
+ .description("Manage cryptographic keys in a Key Vault");
399
+
400
+ kvKey
401
+ .command("list", { isDefault: true })
402
+ .description("List keys in a Key Vault")
403
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
404
+ .option("--profile <subscription>", "Azure subscription name or ID")
405
+ .action(async (opts) => {
406
+ const { keyList } = await import("../azure-keyvault.js");
407
+ await keyList({ vault: opts.vault, profile: opts.profile });
408
+ });
409
+
410
+ kvKey
411
+ .command("show <name>")
412
+ .description("Show key details and rotation policy")
413
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
414
+ .option("--profile <subscription>", "Azure subscription name or ID")
415
+ .action(async (name, opts) => {
416
+ const { keyShow } = await import("../azure-keyvault.js");
417
+ await keyShow({ vault: opts.vault, name, profile: opts.profile });
418
+ });
419
+
420
+ kvKey
421
+ .command("create <name>")
422
+ .description("Create a key with optional auto-rotation policy")
423
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
424
+ .option("--type <kty>", "Key type: RSA, RSA-HSM, EC, EC-HSM, oct, oct-HSM (default: RSA)", "RSA")
425
+ .option("--size <bits>", "RSA key size: 2048, 3072, 4096 (default: 2048)", "2048")
426
+ .option("--curve <curve>", "EC curve: P-256, P-384, P-521 (default: P-256)")
427
+ .option("--ops <operations>", "Space-separated key operations (encrypt decrypt sign verify wrapKey unwrapKey)")
428
+ .option("--rotate-every <duration>", "Auto-rotation interval as ISO 8601 duration (e.g. P90D, P6M, P1Y)")
429
+ .option("--expires-in <duration>", "Key expiry as ISO 8601 duration (e.g. P1Y, P2Y)")
430
+ .option("--notify-before <duration>", "Notify before expiry (default: P30D)")
431
+ .option("--disabled", "Create the key in disabled state")
432
+ .option("--profile <subscription>", "Azure subscription name or ID")
433
+ .action(async (name, opts) => {
434
+ const { keyCreate } = await import("../azure-keyvault.js");
435
+ await keyCreate({
436
+ vault: opts.vault, name, type: opts.type, size: opts.size,
437
+ curve: opts.curve, ops: opts.ops, rotateEvery: opts.rotateEvery,
438
+ expiresIn: opts.expiresIn, notifyBefore: opts.notifyBefore,
439
+ disabled: opts.disabled, profile: opts.profile,
440
+ });
441
+ });
442
+
443
+ kvKey
444
+ .command("rotate <name>")
445
+ .description("Rotate a key on-demand (creates a new version)")
446
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
447
+ .option("--profile <subscription>", "Azure subscription name or ID")
448
+ .action(async (name, opts) => {
449
+ const { keyRotate } = await import("../azure-keyvault.js");
450
+ await keyRotate({ vault: opts.vault, name, profile: opts.profile });
451
+ });
452
+
453
+ const kvRotationPolicy = kvKey
454
+ .command("rotation-policy")
455
+ .description("Manage key auto-rotation policy");
456
+
457
+ kvRotationPolicy
458
+ .command("show <name>")
459
+ .description("Show the rotation policy for a key")
460
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
461
+ .option("--profile <subscription>", "Azure subscription name or ID")
462
+ .action(async (name, opts) => {
463
+ const { keyRotationPolicyShow } = await import("../azure-keyvault.js");
464
+ await keyRotationPolicyShow({ vault: opts.vault, name, profile: opts.profile });
465
+ });
466
+
467
+ kvRotationPolicy
468
+ .command("set <name>")
469
+ .description("Set auto-rotation policy for a key")
470
+ .option("--vault <name>", "Key Vault name (auto-detected if only one)")
471
+ .requiredOption("--rotate-every <duration>", "Rotation interval (ISO 8601: P30D, P90D, P6M, P1Y)")
472
+ .option("--expires-in <duration>", "Key expiry duration (ISO 8601: P1Y, P2Y)")
473
+ .option("--notify-before <duration>", "Notify before expiry (default: P30D)")
474
+ .option("--profile <subscription>", "Azure subscription name or ID")
475
+ .action(async (name, opts) => {
476
+ const { keyRotationPolicySet } = await import("../azure-keyvault.js");
477
+ await keyRotationPolicySet({
478
+ vault: opts.vault, name, rotateEvery: opts.rotateEvery,
479
+ expiresIn: opts.expiresIn, notifyBefore: opts.notifyBefore,
480
+ profile: opts.profile,
481
+ });
482
+ });
483
+
484
+ // ── AKS (Azure Kubernetes Service) ────────────────────────────────────
485
+
486
+ const aks = azure
487
+ .command("aks")
488
+ .description("Manage Foundation on AKS clusters (bootstrapped via Flux)");
489
+
490
+ aks
491
+ .command("up [name]")
492
+ .description("Create an AKS cluster, create GHCR pull secret, and bootstrap Flux (meshxdata/flux)")
493
+ .option("--profile <subscription>", "Azure subscription name or ID")
494
+ .option("--location <region>", "Azure region (default: uaenorth)")
495
+ .option("--node-count <count>", "Initial node count (default: 3)")
496
+ .option("--min-count <count>", "Autoscaler min nodes (default: 1)")
497
+ .option("--max-count <count>", "Autoscaler max nodes (default: 5)")
498
+ .option("--node-vm-size <size>", "Node VM size (default: Standard_D8s_v3)")
499
+ .option("--kubernetes-version <version>", "K8s version (default: 1.30)")
500
+ .option("--tier <tier>", "AKS tier: free | standard | premium (default: standard)")
501
+ .option("--network-plugin <plugin>", "Network plugin: azure | kubenet (default: azure)")
502
+ .option("--max-pods <count>", "Max pods per node (default: 110)")
503
+ .option("--resource-group <name>", "Resource group (default: foundation-aks-rg)")
504
+ .option("--flux-repo <repo>", "GitHub repo for Flux (default: flux)")
505
+ .option("--flux-owner <owner>", "GitHub owner/org for Flux (default: meshxdata)")
506
+ .option("--flux-path <path>", "Path in repo for cluster manifests (default: clusters/<name>)")
507
+ .option("--flux-branch <branch>", "Git branch for Flux (default: main)")
508
+ .option("--github-token <token>", "GitHub PAT for Flux + GHCR pull (default: $GITHUB_TOKEN)")
509
+ .option("--no-flux", "Skip Flux bootstrap")
510
+ .option("--no-postgres", "Skip Postgres Flexible Server provisioning")
511
+ .option("--dai", "Include DAI (Dashboards AI) workloads")
512
+ .action(async (name, opts) => {
513
+ const { aksUp } = await import("../azure-aks.js");
514
+ await aksUp({
515
+ clusterName: name, profile: opts.profile, location: opts.location,
516
+ nodeCount: opts.nodeCount ? Number(opts.nodeCount) : undefined,
517
+ minCount: opts.minCount ? Number(opts.minCount) : undefined,
518
+ maxCount: opts.maxCount ? Number(opts.maxCount) : undefined,
519
+ nodeVmSize: opts.nodeVmSize, kubernetesVersion: opts.kubernetesVersion,
520
+ tier: opts.tier, networkPlugin: opts.networkPlugin, maxPods: opts.maxPods ? Number(opts.maxPods) : undefined,
521
+ resourceGroup: opts.resourceGroup,
522
+ fluxRepo: opts.fluxRepo, fluxOwner: opts.fluxOwner,
523
+ fluxPath: opts.fluxPath, fluxBranch: opts.fluxBranch,
524
+ githubToken: opts.githubToken,
525
+ noFlux: opts.flux === false,
526
+ noPostgres: opts.postgres === false,
527
+ dai: opts.dai === true,
528
+ });
529
+ });
530
+
531
+ aks
532
+ .command("down [name]")
533
+ .description("Destroy the AKS cluster and all workloads")
534
+ .option("--profile <subscription>", "Azure subscription name or ID")
535
+ .option("--yes", "Skip confirmation prompt")
536
+ .action(async (name, opts) => {
537
+ const { aksDown } = await import("../azure-aks.js");
538
+ await aksDown({ clusterName: name, profile: opts.profile, yes: opts.yes });
539
+ });
540
+
541
+ aks
542
+ .command("list")
543
+ .description("List all tracked AKS clusters")
544
+ .option("--profile <subscription>", "Azure subscription name or ID")
545
+ .action(async (opts) => {
546
+ const { aksList } = await import("../azure-aks.js");
547
+ await aksList({ profile: opts.profile });
548
+ });
549
+
550
+ aks
551
+ .command("status [name]")
552
+ .description("Show cluster state, node pools, and Flux health")
553
+ .option("--profile <subscription>", "Azure subscription name or ID")
554
+ .action(async (name, opts) => {
555
+ const { aksStatus } = await import("../azure-aks.js");
556
+ await aksStatus({ clusterName: name, profile: opts.profile });
557
+ });
558
+
559
+ const aksConfig = aks
560
+ .command("config")
561
+ .description("Manage service versions on the AKS cluster");
562
+
563
+ aksConfig
564
+ .command("versions [name]", { isDefault: true })
565
+ .description("Show Foundation service image tags running on the cluster")
566
+ .action(async (name) => {
567
+ const { aksConfigVersions } = await import("../azure-aks.js");
568
+ await aksConfigVersions({ clusterName: name });
569
+ });
570
+
571
+ aks
572
+ .command("terraform [name]")
573
+ .description("Generate Terraform HCL for the AKS cluster and its resources")
574
+ .option("--profile <subscription>", "Azure subscription name or ID")
575
+ .option("--output <file>", "Write HCL to a file instead of stdout")
576
+ .action(async (name, opts) => {
577
+ const { aksTerraform } = await import("../azure-aks.js");
578
+ await aksTerraform({ clusterName: name, profile: opts.profile, output: opts.output });
579
+ });
580
+
581
+ aks
582
+ .command("test [name]")
583
+ .description("Run QA automation tests locally against an AKS cluster")
584
+ .action(async (name) => {
585
+ const { readClusterState, writeClusterState, clusterDomain } = await import("../azure-aks.js");
586
+ const { resolveCliSrc } = await import("../azure-helpers.js");
587
+ const { rootDir } = await import(resolveCliSrc("project.js"));
588
+ const fsp = await import("node:fs/promises");
589
+ const path = await import("node:path");
590
+
591
+ const cluster = readClusterState(name);
592
+ if (!cluster?.clusterName) {
593
+ console.error(chalk.red(`\n No AKS cluster tracked: "${name || "(none active)"}"`));
594
+ console.error(chalk.dim(" Create one: fops azure aks up <name>\n"));
595
+ process.exit(1);
596
+ }
597
+
598
+ const domain = cluster.domain || clusterDomain(cluster.clusterName);
599
+ const baseUrl = `https://${domain}`;
600
+ const apiUrl = `${baseUrl}/api`;
601
+
602
+ const root = rootDir();
603
+ if (!root) {
604
+ console.error(chalk.red("\n Foundation project root not found. Run from the compose repo or set FOUNDATION_ROOT.\n"));
605
+ process.exit(1);
606
+ }
607
+
608
+ const qaDir = path.join(root, "foundation-qa-automation");
609
+ try { await fsp.access(qaDir); } catch {
610
+ console.error(chalk.red("\n foundation-qa-automation/ not found in project root."));
611
+ console.error(chalk.dim(" Run: git submodule update --init\n"));
612
+ process.exit(1);
613
+ }
614
+
615
+ const { execa: execaFn } = await import("execa");
616
+
617
+ // For AKS: use a tracked VM for SSH fallback
618
+ const { listVms: lv, sshCmd: sc, knockForVm: kfv } = await import("../azure.js");
619
+ const { vms: allVms } = lv();
620
+ const vmEntry = Object.entries(allVms).find(([, v]) => v.publicIp);
621
+
622
+ console.log(chalk.dim(` Authenticating against ${apiUrl}…`));
623
+ const auth = await resolveRemoteAuth({
624
+ apiUrl,
625
+ ip: vmEntry?.[1]?.publicIp,
626
+ vmState: vmEntry?.[1],
627
+ execaFn, sshCmd: sc, knockForVm: kfv, suppressTlsWarning,
628
+ });
629
+ let { bearerToken, qaUser, qaPass, useTokenMode } = auth;
630
+
631
+ if (!bearerToken && !qaUser) {
632
+ console.error(chalk.red("\n No credentials found (local or remote)."));
633
+ console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD in env or ~/.fops.json\n"));
634
+ process.exit(1);
635
+ }
636
+
637
+ // Write QA .env
638
+ const envPath = path.join(qaDir, ".env");
639
+ const examplePath = path.join(qaDir, ".env.example");
640
+ let envContent;
641
+ try { envContent = await fsp.readFile(examplePath, "utf8"); } catch {
642
+ envContent = await fsp.readFile(envPath, "utf8").catch(() => "");
643
+ }
644
+ const setVar = (content, key, value) => {
645
+ const re = new RegExp(`^${key}=.*`, "m");
646
+ return re.test(content) ? content.replace(re, `${key}=${value}`) : content + `\n${key}=${value}`;
647
+ };
648
+ envContent = setVar(envContent, "API_URL", apiUrl);
649
+ envContent = setVar(envContent, "DEV_API_URL", apiUrl);
650
+ envContent = setVar(envContent, "LIVE_API_URL", apiUrl);
651
+ envContent = setVar(envContent, "QA_USERNAME", qaUser);
652
+ envContent = setVar(envContent, "QA_PASSWORD", qaPass);
653
+ envContent = setVar(envContent, "QA_X_ACCOUNT", "root");
654
+ envContent = setVar(envContent, "ADMIN_USERNAME", qaUser);
655
+ envContent = setVar(envContent, "ADMIN_PASSWORD", qaPass);
656
+ envContent = setVar(envContent, "ADMIN_X_ACCOUNT", "root");
657
+ envContent = setVar(envContent, "CDO_USERNAME", qaUser);
658
+ envContent = setVar(envContent, "CDO_PASSWORD", qaPass);
659
+ envContent = setVar(envContent, "CDO_X_ACCOUNT", "root");
660
+ envContent = setVar(envContent, "OWNER_EMAIL", qaUser);
661
+ envContent = setVar(envContent, "OWNER_NAME", "Foundation Operator");
662
+ if (bearerToken) {
663
+ envContent = setVar(envContent, "BEARER_TOKEN", bearerToken);
664
+ envContent = setVar(envContent, "TOKEN_AUTH0", bearerToken);
665
+ }
666
+ await fsp.writeFile(envPath, envContent);
667
+ console.log(chalk.green(` ✓ Configured QA .env → ${apiUrl}`));
668
+
669
+ // Ensure venv + deps
670
+ try { await fsp.access(path.join(qaDir, "venv")); } catch {
671
+ console.log(chalk.cyan(" Setting up QA automation environment…"));
672
+ await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
673
+ await execaFn("bash", ["-c", "source venv/bin/activate && pip install -r requirements.txt && playwright install"], { cwd: qaDir, stdio: "inherit" });
674
+ }
675
+
676
+ const authMode = useTokenMode ? "bearer token (--use-token)" : `user/pass (${qaUser})`;
677
+ console.log(chalk.cyan(`\n Running QA tests against AKS ${cluster.clusterName} (${baseUrl}) [${authMode}]…\n`));
678
+ const pytestArgsList = [
679
+ "tests/",
680
+ "--env", "staging",
681
+ "--role", "FOUNDATION_ADMIN",
682
+ "--numprocesses=0",
683
+ "-v",
684
+ "--durations=0",
685
+ "--html=./playwright-report/report.html",
686
+ "--self-contained-html",
687
+ "--ignore=tests/e2e/landscape",
688
+ "--ignore=tests/e2e/procedures",
689
+ "--ignore=tests/e2e/upload_file_s3",
690
+ "--ignore=tests/roles",
691
+ "--ignore=tests/unit/data_product/test_data_product_consumers_v2.py",
692
+ "--ignore=tests/unit/data_product/test_data_product_query.py",
693
+ "--ignore=tests/unit/data_product/test_data_product_quality_v2.py",
694
+ "--ignore=tests/unit/data_product/test_data_product_schema_v2.py",
695
+ ];
696
+ if (useTokenMode) pytestArgsList.push("--use-token");
697
+ const pytestArgs = pytestArgsList.join(" ");
698
+
699
+ const testEnv = {
700
+ ...process.env,
701
+ API_URL: apiUrl,
702
+ DEV_API_URL: apiUrl,
703
+ LIVE_API_URL: apiUrl,
704
+ ROLE_NAME: "FOUNDATION_ADMIN",
705
+ QA_USERNAME: qaUser,
706
+ QA_PASSWORD: qaPass,
707
+ ADMIN_USERNAME: qaUser,
708
+ ADMIN_PASSWORD: qaPass,
709
+ QA_X_ACCOUNT: "root",
710
+ ADMIN_X_ACCOUNT: "root",
711
+ CDO_USERNAME: qaUser,
712
+ CDO_PASSWORD: qaPass,
713
+ CDO_X_ACCOUNT: "root",
714
+ OWNER_EMAIL: qaUser,
715
+ OWNER_NAME: "Foundation Operator",
716
+ };
717
+ if (bearerToken) {
718
+ testEnv.BEARER_TOKEN = bearerToken;
719
+ testEnv.TOKEN_AUTH0 = bearerToken;
720
+ }
721
+
722
+ const aksStartMs = Date.now();
723
+ const aksProc = execaFn(
724
+ "bash",
725
+ ["-c", `source venv/bin/activate && pytest ${pytestArgs}`],
726
+ { cwd: qaDir, timeout: 600_000, reject: false, env: testEnv },
727
+ );
728
+ let aksCaptured = "";
729
+ aksProc.stdout?.on("data", (d) => { const s = d.toString(); aksCaptured += s; process.stdout.write(s); });
730
+ aksProc.stderr?.on("data", (d) => { const s = d.toString(); aksCaptured += s; process.stderr.write(s); });
731
+ const { exitCode } = await aksProc;
732
+ const aksWallSec = Math.round((Date.now() - aksStartMs) / 1000);
733
+
734
+ const aksCounts = parsePytestSummary(aksCaptured);
735
+ const aksTiming = parsePytestDurations(aksCaptured);
736
+ const qaResult = {
737
+ passed: exitCode === 0,
738
+ exitCode,
739
+ at: new Date().toISOString(),
740
+ ...(aksCounts.passed != null && { numPassed: aksCounts.passed }),
741
+ ...(aksCounts.failed != null && { numFailed: aksCounts.failed }),
742
+ ...(aksCounts.error != null && { numErrors: aksCounts.error }),
743
+ ...(aksCounts.errors != null && { numErrors: aksCounts.errors }),
744
+ ...(aksCounts.skipped != null && { numSkipped: aksCounts.skipped }),
745
+ durationSec: aksCounts.durationSec || aksWallSec,
746
+ ...(aksTiming && { timing: aksTiming }),
747
+ };
748
+ writeClusterState(cluster.clusterName, { qa: qaResult });
749
+
750
+ if (exitCode === 0) {
751
+ console.log(chalk.green("\n ✓ QA tests passed\n"));
752
+ } else {
753
+ console.error(chalk.red(`\n QA tests failed (exit ${exitCode}).`));
754
+ console.error(chalk.dim(` Report: ${path.join(qaDir, "playwright-report", "report.html")}\n`));
755
+ process.exitCode = 1;
756
+ }
757
+
758
+ try {
759
+ const { resultsPush } = await import("../azure-results.js");
760
+ await resultsPush(cluster.clusterName, qaResult, { quiet: false });
761
+ } catch (pushErr) {
762
+ console.log(chalk.dim(` (Result push skipped: ${pushErr.message})`));
763
+ }
764
+ });
765
+
766
+ aks
767
+ .command("kubeconfig [name]")
768
+ .description("Merge cluster kubeconfig into ~/.kube/config")
769
+ .option("--profile <subscription>", "Azure subscription name or ID")
770
+ .option("--admin", "Get admin credentials (cluster-admin role)")
771
+ .action(async (name, opts) => {
772
+ const { aksKubeconfig } = await import("../azure-aks.js");
773
+ await aksKubeconfig({ clusterName: name, profile: opts.profile, admin: opts.admin });
774
+ });
775
+
776
+ aks
777
+ .command("bootstrap [clusterName]")
778
+ .description("Create demo data mesh on the cluster (same as fops bootstrap, targeting AKS backend)")
779
+ .option("--api-url <url>", "Foundation backend API URL (e.g. https://foundation.example.com/api)")
780
+ .option("--yes", "Use credentials from env or ~/.fops.json, skip prompt")
781
+ .option("--profile <subscription>", "Azure subscription name or ID")
782
+ .action(async (clusterName, opts) => {
783
+ const { aksDataBootstrap } = await import("../azure-aks.js");
784
+ await aksDataBootstrap({
785
+ clusterName,
786
+ profile: opts.profile,
787
+ apiUrl: opts.apiUrl,
788
+ yes: opts.yes,
789
+ });
790
+ });
791
+
792
+ // ── Node pool management ───────────────────────────────────────────────
793
+
794
+ const nodePool = aks
795
+ .command("node-pool")
796
+ .description("Manage AKS node pools");
797
+
798
+ nodePool
799
+ .command("add [clusterName]")
800
+ .description("Add a node pool to the cluster")
801
+ .requiredOption("--pool-name <name>", "Node pool name")
802
+ .option("--profile <subscription>", "Azure subscription name or ID")
803
+ .option("--node-count <count>", "Node count (default: 3)")
804
+ .option("--node-vm-size <size>", "VM size (default: Standard_D8s_v3)")
805
+ .option("--mode <mode>", "Pool mode: System | User")
806
+ .option("--labels <labels>", "Node labels (key=value pairs)")
807
+ .option("--taints <taints>", "Node taints (key=value:effect)")
808
+ .option("--max-pods <count>", "Max pods per node")
809
+ .action(async (clusterName, opts) => {
810
+ const { aksNodePoolAdd } = await import("../azure-aks.js");
811
+ await aksNodePoolAdd({
812
+ clusterName, profile: opts.profile, poolName: opts.poolName,
813
+ nodeCount: opts.nodeCount ? Number(opts.nodeCount) : undefined,
814
+ nodeVmSize: opts.nodeVmSize, mode: opts.mode,
815
+ labels: opts.labels, taints: opts.taints,
816
+ maxPods: opts.maxPods ? Number(opts.maxPods) : undefined,
817
+ });
818
+ });
819
+
820
+ nodePool
821
+ .command("remove [clusterName]")
822
+ .description("Remove a node pool from the cluster")
823
+ .requiredOption("--pool-name <name>", "Node pool name to remove")
824
+ .option("--profile <subscription>", "Azure subscription name or ID")
825
+ .action(async (clusterName, opts) => {
826
+ const { aksNodePoolRemove } = await import("../azure-aks.js");
827
+ await aksNodePoolRemove({ clusterName, profile: opts.profile, poolName: opts.poolName });
828
+ });
829
+
830
+ // ── Flux subcommands ───────────────────────────────────────────────────
831
+
832
+ const flux = aks
833
+ .command("flux")
834
+ .description("Manage Flux GitOps on the AKS cluster");
835
+
836
+ flux
837
+ .command("init <clusterName>")
838
+ .description("Scaffold cluster manifests from the Foundation Flux template")
839
+ .requiredOption("--flux-repo <path>", "Path to local flux repo clone")
840
+ .option("--overlay <name>", "Overlay name for app kustomizations (default: <name>-azure)")
841
+ .option("--namespace <ns>", "Kubernetes namespace for the tenant (default: velora)")
842
+ .option("--postgres-host <host>", "Postgres hostname")
843
+ .option("--acr-username <user>", "ACR pull username")
844
+ .option("--acr-password <pass>", "ACR pull password")
845
+ .action(async (clusterName, opts) => {
846
+ const { aksFluxInit } = await import("../azure-aks.js");
847
+ await aksFluxInit({
848
+ clusterName, fluxRepo: opts.fluxRepo,
849
+ overlay: opts.overlay, namespace: opts.namespace,
850
+ postgresHost: opts.postgresHost,
851
+ acrUsername: opts.acrUsername, acrPassword: opts.acrPassword,
852
+ });
853
+ });
854
+
855
+ flux
856
+ .command("bootstrap [clusterName]")
857
+ .description("Bootstrap Flux on the cluster with a GitHub repo")
858
+ .requiredOption("--flux-repo <repo>", "GitHub repo name")
859
+ .requiredOption("--flux-owner <owner>", "GitHub owner/org")
860
+ .option("--flux-path <path>", "Path in repo for cluster manifests (default: clusters/<name>)")
861
+ .option("--flux-branch <branch>", "Git branch (default: main)")
862
+ .option("--github-token <token>", "GitHub PAT (default: $GITHUB_TOKEN)")
863
+ .option("--profile <subscription>", "Azure subscription name or ID")
864
+ .action(async (clusterName, opts) => {
865
+ const { aksFluxBootstrap } = await import("../azure-aks.js");
866
+ await aksFluxBootstrap({
867
+ clusterName, profile: opts.profile,
868
+ repo: opts.fluxRepo, owner: opts.fluxOwner,
869
+ path: opts.fluxPath, branch: opts.fluxBranch,
870
+ githubToken: opts.githubToken,
871
+ });
872
+ });
873
+
874
+ flux
875
+ .command("status [clusterName]")
876
+ .description("Show Flux sources, kustomizations, and helm releases")
877
+ .action(async (clusterName) => {
878
+ const { aksFluxStatus } = await import("../azure-aks.js");
879
+ await aksFluxStatus({ clusterName });
880
+ });
881
+
882
+ flux
883
+ .command("reconcile [clusterName]")
884
+ .description("Trigger a Flux reconciliation (pull + apply)")
885
+ .option("--source <name>", "Git source name (default: flux-system)")
886
+ .action(async (clusterName, opts) => {
887
+ const { aksFluxReconcile } = await import("../azure-aks.js");
888
+ await aksFluxReconcile({ clusterName, source: opts.source });
889
+ });
890
+ }