@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,754 @@
1
+ /**
2
+ * azure-ops-config.js
3
+ * Config and version management operations for Azure VMs.
4
+ * Extracted from azure-ops.js for maintainability.
5
+ */
6
+
7
+ import chalk from "chalk";
8
+ import { listVms, requireVmState } from "./azure-state.js";
9
+ import {
10
+ DEFAULTS, DIM, OK, WARN, ERR,
11
+ banner, hint, kvLine, nameArg,
12
+ lazyExeca,
13
+ sshCmd, knockForVm,
14
+ resolveCliSrc,
15
+ } from "./azure-helpers.js";
16
+ import { closeKnock } from "./port-knock.js";
17
+
18
+ // ── GHCR: resolve latest tag via gh CLI ──────────────────────────────────────
19
+
20
+ const GHCR_ORG = "meshxdata";
21
+
22
+ /**
23
+ * Pick a short error-like line from command output (git, etc.) for inclusion in reason.
24
+ */
25
+ function pickErrorLine(output) {
26
+ const lines = (output || "").trim().split("\n").map((l) => l.trim()).filter(Boolean);
27
+ if (lines.length === 0) return "";
28
+ const errorLike = /error|failed|denied|fatal|refused|cannot|unable|invalid|conflict|Permission/i;
29
+ for (let i = lines.length - 1; i >= 0; i--) {
30
+ if (errorLike.test(lines[i]) && lines[i].length < 120) return lines[i];
31
+ }
32
+ return lines[lines.length - 1].length < 120 ? lines[lines.length - 1] : "";
33
+ }
34
+
35
+ /**
36
+ * Pick the most useful line from make download / docker pull output for a failure message.
37
+ * Prefers a line that mentions both an image and an error; then any error line; then last non-noise.
38
+ */
39
+ function pickPullErrorLine(output) {
40
+ const lines = (output || "").trim().split("\n").map((l) => l.trim()).filter(Boolean);
41
+ if (lines.length === 0) return "";
42
+ const errorLike = /error|failed|denied|unauthorized|not found|manifest|invalid|refused|required/i;
43
+ const imageRef = /ghcr\.io\/[\w.-]+\/[\w.-]+/;
44
+ const noise = /^(?:[\da-f]{12}\s+)?(?:Download complete|Pull complete|Pulled|Extracting|Waiting)$/i;
45
+ for (let i = lines.length - 1; i >= 0; i--) {
46
+ const line = lines[i];
47
+ if (imageRef.test(line) && errorLike.test(line)) return line;
48
+ }
49
+ for (let i = lines.length - 1; i >= 0; i--) {
50
+ const line = lines[i];
51
+ if (errorLike.test(line)) return line;
52
+ }
53
+ for (let i = lines.length - 1; i >= 0; i--) {
54
+ if (imageRef.test(lines[i])) return lines[i];
55
+ }
56
+ for (let i = lines.length - 1; i >= 0; i--) {
57
+ if (!noise.test(lines[i])) return lines[i];
58
+ }
59
+ return lines[lines.length - 1];
60
+ }
61
+
62
+ /**
63
+ * Parse make download / docker output for image:tag that failed (not found).
64
+ * Returns [{ image: "ghcr.io/org/name", tag: "0.3.65" }, ...].
65
+ */
66
+ function parseNotFoundImages(output) {
67
+ if (!output?.trim()) return [];
68
+ const seen = new Set();
69
+ const out = [];
70
+ // "Image ghcr.io/meshxdata/foundation-backend:0.3.65 failed to resolve reference"
71
+ // "failed to resolve reference \"...\": ... not found" (image:tag appears before "not found")
72
+ const re = /(ghcr\.io\/[^/]+\/[^:]+):([^\s"']+).*?(?:failed|not found)/gi;
73
+ let m;
74
+ while ((m = re.exec(output)) !== null) {
75
+ const key = `${m[1]}:${m[2]}`;
76
+ if (!seen.has(key)) {
77
+ seen.add(key);
78
+ out.push({ image: m[1], tag: m[2] });
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ /**
85
+ * Get latest available tag for a GHCR image via gh api (org packages container versions).
86
+ * Returns tag string or null if not ghcr.io/org/... or gh api fails.
87
+ * Prefers semver-like tag, then "compose", then first tag.
88
+ */
89
+ async function getLatestTagForGhcrImage(execa, imageFull) {
90
+ const match = imageFull.match(/^ghcr\.io\/([^/]+)\/(.+)$/);
91
+ if (!match) return null;
92
+ const [, org, pkg] = match;
93
+ try {
94
+ const { stdout, exitCode } = await execa("gh", [
95
+ "api",
96
+ `orgs/${org}/packages/container/${pkg}/versions`,
97
+ "--jq", "if length == 0 then null else (.[0].metadata.container.tags // []) end",
98
+ ], { timeout: 15000, reject: false });
99
+ if (exitCode !== 0 || !stdout?.trim() || stdout === "null") return null;
100
+ let tags = [];
101
+ try {
102
+ tags = JSON.parse(stdout);
103
+ } catch {
104
+ return null;
105
+ }
106
+ if (!Array.isArray(tags) || tags.length === 0) return null;
107
+ const semver = tags.find(t => /^\d+\.\d+\.\d+(-.+)?$/.test(t));
108
+ if (semver) return semver;
109
+ if (tags.includes("compose")) return "compose";
110
+ if (tags.includes("latest")) return "latest";
111
+ return tags[0];
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ // ── config versions (remote component image tags) ────────────────────────────
118
+
119
+ export const FOUNDATION_COMPONENTS = {
120
+ backend: { label: "Backend", image: "ghcr.io/meshxdata/foundation-backend", envKey: "BACKEND" },
121
+ frontend: { label: "Frontend", image: "ghcr.io/meshxdata/foundation-frontend", envKey: "FRONTEND" },
122
+ processor: { label: "Processor", image: "ghcr.io/meshxdata/foundation-processor", envKey: "PROCESSOR" },
123
+ watcher: { label: "Watcher", image: "ghcr.io/meshxdata/foundation-watcher", envKey: "WATCHER" },
124
+ scheduler: { label: "Scheduler", image: "ghcr.io/meshxdata/foundation-scheduler", envKey: "SCHEDULER" },
125
+ storage: { label: "Storage", image: "ghcr.io/meshxdata/foundation-storage-engine", envKey: "STORAGE" },
126
+ hive: { label: "Hive Metastore", image: "ghcr.io/meshxdata/foundation-hive-metastore", envKey: "HIVE" },
127
+ data: { label: "Data", image: "ghcr.io/meshxdata/foundation-data", envKey: "DATA" },
128
+ dataBase: { label: "Data Base", image: "ghcr.io/meshxdata/foundation-data-base", envKey: "DATA_BASE" },
129
+ };
130
+
131
+ function trimEnvValue(val) {
132
+ if (val == null) return null;
133
+ return val.trim().replace(/\s*#.*$/, "").trim().replace(/^["']|["']$/g, "") || null;
134
+ }
135
+
136
+ function parseImageTagFromEnv(envContent) {
137
+ if (!envContent?.trim()) return null;
138
+ const line = envContent.match(/^\s*IMAGE_TAG\s*=\s*(.+)/m)?.[1];
139
+ if (line == null) return null;
140
+ return trimEnvValue(line);
141
+ }
142
+
143
+ function parsePerComponentTagsFromEnv(envContent) {
144
+ const out = {};
145
+ if (!envContent?.trim()) return out;
146
+ for (const key of Object.keys(FOUNDATION_COMPONENTS)) {
147
+ const envKey = FOUNDATION_COMPONENTS[key].envKey;
148
+ const varName = `IMAGE_TAG_${envKey}`;
149
+ const re = new RegExp(`^\\s*${varName}\\s*=[ \\t]*([^\\n\\r]*)`, "m");
150
+ const m = envContent.match(re);
151
+ if (m) {
152
+ const v = trimEnvValue(m[1]);
153
+ if (v) out[varName] = v;
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ export function parseComponentVersions(composeContent, envContent) {
160
+ const envTag = parseImageTagFromEnv(envContent);
161
+ const perComponent = parsePerComponentTagsFromEnv(envContent);
162
+
163
+ const versions = {};
164
+ for (const [key, comp] of Object.entries(FOUNDATION_COMPONENTS)) {
165
+ const escapedImage = comp.image.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ const re = new RegExp(`image:\\s*${escapedImage}:\\s*(.+)`, "m");
167
+ const m = composeContent.match(re);
168
+ if (m) {
169
+ const rawTag = m[1].trim();
170
+ const isEnvRef = rawTag.includes("${IMAGE_TAG");
171
+ const componentVar = `IMAGE_TAG_${comp.envKey}`;
172
+ const effectiveTag = isEnvRef
173
+ ? (perComponent[componentVar] ?? envTag ?? "compose")
174
+ : rawTag;
175
+ versions[key] = { ...comp, rawTag, effectiveTag, isEnvRef };
176
+ }
177
+ }
178
+ return { versions, globalTag: envTag || null };
179
+ }
180
+
181
+ export function applyVersionChanges(composeContent, changes) {
182
+ let result = composeContent;
183
+ for (const { image, oldPattern, newTag } of changes) {
184
+ const escapedImage = image.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
185
+ const escapedOld = oldPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ const re = new RegExp(`(image:\\s*${escapedImage}:)\\s*${escapedOld}`, "g");
187
+ result = result.replace(re, `$1${newTag}`);
188
+ }
189
+ return result;
190
+ }
191
+
192
+ // ── azureConfig (feature flags) ──────────────────────────────────────────────
193
+
194
+ export async function azureConfig(opts = {}) {
195
+ const execa = await lazyExeca();
196
+ const state = requireVmState(opts.vmName);
197
+ const ip = state.publicIp;
198
+ const adminUser = DEFAULTS.adminUser;
199
+
200
+ if (!ip) { console.error(chalk.red("\n No IP. Is the VM running? Try: fops azure start\n")); process.exit(1); }
201
+
202
+ await knockForVm(state);
203
+
204
+ const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
205
+
206
+ hint("Reading feature flags from VM…");
207
+ const { stdout: composeContent, exitCode } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
208
+ if (exitCode !== 0 || !composeContent?.trim()) {
209
+ console.error(chalk.red("\n Could not read docker-compose.yaml from VM.\n"));
210
+ process.exit(1);
211
+ }
212
+
213
+ const { KNOWN_FLAGS, parseComposeFlagsFromContent, applyComposeFlagChanges } =
214
+ await import(resolveCliSrc("feature-flags.js"));
215
+
216
+ const composeFlags = parseComposeFlagsFromContent(composeContent);
217
+
218
+ const allFlags = {};
219
+ for (const [name, info] of Object.entries(composeFlags)) {
220
+ allFlags[name] = { ...info, inCompose: true };
221
+ }
222
+ for (const name of Object.keys(KNOWN_FLAGS)) {
223
+ if (!allFlags[name]) {
224
+ allFlags[name] = { value: false, services: new Set(), lines: [], inCompose: false };
225
+ }
226
+ }
227
+
228
+ const flagNames = Object.keys(allFlags).sort();
229
+
230
+ console.log(chalk.bold.cyan("\n Feature Flags") + chalk.dim(` — ${state.vmName} (${ip})\n`));
231
+
232
+ for (const name of flagNames) {
233
+ const flag = allFlags[name];
234
+ const label = KNOWN_FLAGS[name] || name;
235
+ const services = flag.services.size > 0 ? chalk.dim(` (${[...flag.services].join(", ")})`) : "";
236
+ if (flag.value) {
237
+ console.log(chalk.green(` ✓ ${label}`) + services);
238
+ } else {
239
+ console.log(chalk.dim(` · ${label}`) + services);
240
+ }
241
+ }
242
+ console.log("");
243
+
244
+ const { getInquirer } = await import(resolveCliSrc("lazy.js"));
245
+ const choices = flagNames.map((name) => ({
246
+ name: KNOWN_FLAGS[name] || name,
247
+ value: name,
248
+ checked: allFlags[name].value,
249
+ }));
250
+
251
+ const { enabled } = await (await getInquirer()).prompt([{
252
+ type: "checkbox",
253
+ name: "enabled",
254
+ message: "Toggle feature flags:",
255
+ choices,
256
+ }]);
257
+
258
+ const changes = [];
259
+ const affectedServices = new Set();
260
+
261
+ for (const name of flagNames) {
262
+ const flag = allFlags[name];
263
+ const newValue = enabled.includes(name);
264
+
265
+ if (newValue !== flag.value) {
266
+ if (flag.inCompose) {
267
+ for (const line of flag.lines) {
268
+ changes.push({ lineNum: line.lineNum, newValue: String(newValue) });
269
+ }
270
+ for (const svc of flag.services) affectedServices.add(svc);
271
+ } else if (newValue) {
272
+ console.log(chalk.yellow(` ⚠ ${KNOWN_FLAGS[name] || name} not in docker-compose.yaml — add it to service environments to take effect`));
273
+ }
274
+ }
275
+ }
276
+
277
+ if (changes.length === 0) {
278
+ console.log(chalk.dim("\n No changes.\n"));
279
+ if (state.knockSequence?.length) {
280
+ await closeKnock(ssh, { quiet: true });
281
+ }
282
+ return;
283
+ }
284
+
285
+ const updatedContent = applyComposeFlagChanges(composeContent, changes);
286
+
287
+ hint("Applying changes to VM…");
288
+ const b64 = Buffer.from(updatedContent).toString("base64");
289
+ const { exitCode: writeCode } = await sshCmd(execa, ip, adminUser,
290
+ `echo '${b64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`,
291
+ 30000,
292
+ );
293
+ if (writeCode !== 0) {
294
+ console.error(chalk.red("\n Failed to write docker-compose.yaml on VM.\n"));
295
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
296
+ process.exit(1);
297
+ }
298
+ console.log(chalk.green(`\n ✓ Updated ${changes.length} flag value(s) on VM`));
299
+
300
+ if (affectedServices.size === 0) {
301
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
302
+ console.log("");
303
+ return;
304
+ }
305
+
306
+ const serviceList = [...affectedServices];
307
+ console.log(chalk.dim(` Affected: ${serviceList.join(", ")}`));
308
+
309
+ const { restart } = await (await getInquirer()).prompt([{
310
+ type: "confirm",
311
+ name: "restart",
312
+ message: `Restart ${serviceList.length} service(s) on VM?`,
313
+ default: true,
314
+ }]);
315
+
316
+ if (restart) {
317
+ const svcArgs = serviceList.join(" ");
318
+ console.log(chalk.cyan(`\n ▶ docker compose up -d --remove-orphans ${svcArgs}\n`));
319
+ await sshCmd(execa, ip, adminUser,
320
+ `cd /opt/foundation-compose && docker compose up -d --remove-orphans ${svcArgs}`,
321
+ 120000,
322
+ );
323
+ console.log(chalk.green("\n ✓ Services restarted on VM.\n"));
324
+ } else {
325
+ console.log(chalk.dim("\n Changes saved. Restart manually: fops azure deploy\n"));
326
+ }
327
+
328
+ if (state.knockSequence?.length) {
329
+ await closeKnock(ssh, { quiet: true });
330
+ }
331
+ }
332
+
333
+ // ── azureConfigVersions (component versions) ─────────────────────────────────
334
+
335
+ export async function azureConfigVersions(opts = {}) {
336
+ const execa = await lazyExeca();
337
+ const state = requireVmState(opts.vmName);
338
+ const ip = state.publicIp;
339
+ const adminUser = DEFAULTS.adminUser;
340
+
341
+ if (!ip) { console.error(chalk.red("\n No IP. Is the VM running? Try: fops azure start\n")); process.exit(1); }
342
+
343
+ await knockForVm(state);
344
+
345
+ const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
346
+
347
+ hint("Reading component versions from VM…");
348
+ const { stdout: composeContent, exitCode } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
349
+ if (exitCode !== 0 || !composeContent?.trim()) {
350
+ console.error(chalk.red("\n Could not read docker-compose.yaml from VM.\n"));
351
+ process.exit(1);
352
+ }
353
+
354
+ const { stdout: envContent } = await ssh("cat /opt/foundation-compose/.env 2>/dev/null || echo ''");
355
+
356
+ const { versions, globalTag } = parseComponentVersions(composeContent, envContent);
357
+
358
+ const n = nameArg(state.vmName);
359
+ console.log(chalk.bold.cyan("\n Component Versions") + chalk.dim(` — ${state.vmName} (${ip})\n`));
360
+
361
+ if (globalTag) {
362
+ console.log(chalk.dim(` IMAGE_TAG=${globalTag} (from .env)\n`));
363
+ }
364
+
365
+ const maxLabel = Math.max(...Object.values(versions).map(v => v.label.length));
366
+ for (const [key, v] of Object.entries(versions)) {
367
+ const tagDisplay = v.isEnvRef
368
+ ? `${chalk.white(v.effectiveTag)} ${chalk.dim("(via IMAGE_TAG)")}`
369
+ : `${chalk.white(v.effectiveTag)} ${chalk.yellow("(hardcoded in compose)")}`;
370
+ console.log(` ${chalk.cyan(v.label.padEnd(maxLabel + 2))} ${tagDisplay}`);
371
+ }
372
+ console.log("");
373
+
374
+ const { getInquirer } = await import(resolveCliSrc("lazy.js"));
375
+ const inquirer = await getInquirer();
376
+
377
+ // Ask: set all at once, or pick individual components
378
+ const { mode } = await inquirer.prompt([{
379
+ type: "list",
380
+ name: "mode",
381
+ message: "How do you want to set versions?",
382
+ choices: [
383
+ { name: "Set all components to one tag (IMAGE_TAG)", value: "global" },
384
+ { name: "Set individual component tags", value: "individual" },
385
+ { name: "Cancel", value: "cancel" },
386
+ ],
387
+ }]);
388
+
389
+ if (mode === "cancel") {
390
+ console.log(chalk.dim("\n Cancelled.\n"));
391
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
392
+ return;
393
+ }
394
+
395
+ const envPerComponent = {};
396
+ const composeChanges = [];
397
+ let envChanges = null;
398
+ const affectedComponents = [];
399
+
400
+ if (mode === "global") {
401
+ const { tag } = await inquirer.prompt([{
402
+ type: "input",
403
+ name: "tag",
404
+ message: "Tag for all components:",
405
+ default: globalTag || "compose",
406
+ }]);
407
+
408
+ const trimmed = tag.trim();
409
+ if (!trimmed) {
410
+ console.log(chalk.dim("\n No tag entered.\n"));
411
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
412
+ return;
413
+ }
414
+
415
+ envChanges = trimmed;
416
+ for (const [key, v] of Object.entries(versions)) {
417
+ if (v.effectiveTag !== trimmed) affectedComponents.push(key);
418
+ }
419
+ } else {
420
+ // Individual mode — pick components; write IMAGE_TAG_* to .env only (no compose edit)
421
+ const componentKeys = Object.keys(versions);
422
+ const selected = [];
423
+
424
+ while (true) {
425
+ const remaining = componentKeys.filter(k => !selected.includes(k));
426
+ const choices = [
427
+ ...remaining.map(key => ({
428
+ name: `${versions[key].label} (${versions[key].effectiveTag})`,
429
+ value: key,
430
+ })),
431
+ { name: chalk.dim(selected.length ? "── Done" : "── Cancel"), value: "_done" },
432
+ ];
433
+
434
+ const { pick } = await inquirer.prompt([{
435
+ type: "list",
436
+ name: "pick",
437
+ message: selected.length
438
+ ? `Selected: ${selected.map(k => versions[k].label).join(", ")}. Add more?`
439
+ : "Pick a component:",
440
+ choices,
441
+ }]);
442
+
443
+ if (pick === "_done") break;
444
+
445
+ const v = versions[pick];
446
+ const { tag } = await inquirer.prompt([{
447
+ type: "input",
448
+ name: "tag",
449
+ message: `Tag for ${v.label}:`,
450
+ default: v.effectiveTag,
451
+ }]);
452
+
453
+ const trimmed = tag.trim();
454
+ if (!trimmed || trimmed === v.effectiveTag) continue;
455
+
456
+ selected.push(pick);
457
+ envPerComponent[`IMAGE_TAG_${v.envKey}`] = trimmed;
458
+ affectedComponents.push(pick);
459
+
460
+ if (remaining.length <= 1) break;
461
+ }
462
+
463
+ if (!selected.length) {
464
+ console.log(chalk.dim("\n No components selected.\n"));
465
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
466
+ return;
467
+ }
468
+ }
469
+
470
+ if (Object.keys(envPerComponent).length === 0 && composeChanges.length === 0 && !envChanges) {
471
+ console.log(chalk.dim("\n No changes.\n"));
472
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
473
+ return;
474
+ }
475
+
476
+ let envAfterGlobal = envContent || "";
477
+ // Apply IMAGE_TAG to .env (global mode)
478
+ if (envChanges) {
479
+ hint(`Setting IMAGE_TAG=${envChanges} in .env…`);
480
+ if (/^\s*IMAGE_TAG\s*=/m.test(envAfterGlobal)) {
481
+ envAfterGlobal = envAfterGlobal.replace(/^\s*IMAGE_TAG\s*=.*/m, `IMAGE_TAG=${envChanges}`);
482
+ } else {
483
+ envAfterGlobal = envAfterGlobal.trimEnd() + (envAfterGlobal.trim() ? "\n" : "") + `IMAGE_TAG=${envChanges}\n`;
484
+ }
485
+ const envB64 = Buffer.from(envAfterGlobal).toString("base64");
486
+ const { exitCode: envWriteCode } = await sshCmd(execa, ip, adminUser,
487
+ `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`,
488
+ 30000,
489
+ );
490
+ if (envWriteCode !== 0) {
491
+ console.error(chalk.red("\n Failed to write .env on VM.\n"));
492
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
493
+ process.exit(1);
494
+ }
495
+ console.log(chalk.green(` ✓ IMAGE_TAG=${envChanges}`));
496
+ }
497
+
498
+ // Apply per-component tags to .env only (no compose edit)
499
+ if (Object.keys(envPerComponent).length > 0) {
500
+ hint("Setting per-component tags in .env…");
501
+ let currentEnv = envAfterGlobal;
502
+ for (const [varName, tag] of Object.entries(envPerComponent)) {
503
+ const re = new RegExp(`^\\s*${varName}\\s*=.*`, "m");
504
+ if (re.test(currentEnv)) {
505
+ currentEnv = currentEnv.replace(re, `${varName}=${tag}`);
506
+ } else {
507
+ currentEnv = currentEnv.trimEnd() + (currentEnv.trim() ? "\n" : "") + `${varName}=${tag}\n`;
508
+ }
509
+ const comp = Object.values(FOUNDATION_COMPONENTS).find(c => `IMAGE_TAG_${c.envKey}` === varName);
510
+ console.log(chalk.green(` ✓ ${comp?.label || varName}: ${tag}`));
511
+ }
512
+ const envB64 = Buffer.from(currentEnv).toString("base64");
513
+ const { exitCode: envWriteCode } = await sshCmd(execa, ip, adminUser,
514
+ `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`,
515
+ 30000,
516
+ );
517
+ if (envWriteCode !== 0) {
518
+ console.error(chalk.red("\n Failed to write .env on VM.\n"));
519
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
520
+ process.exit(1);
521
+ }
522
+ }
523
+
524
+ // Compose edits only for legacy hardcoded-tag fixes (e.g. make download fallback)
525
+ if (composeChanges.length > 0) {
526
+ hint("Updating docker-compose.yaml…");
527
+ const updatedContent = applyVersionChanges(composeContent, composeChanges);
528
+ const composeB64 = Buffer.from(updatedContent).toString("base64");
529
+ const { exitCode: composeWriteCode } = await sshCmd(execa, ip, adminUser,
530
+ `echo '${composeB64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`,
531
+ 30000,
532
+ );
533
+ if (composeWriteCode !== 0) {
534
+ console.error(chalk.red("\n Failed to write docker-compose.yaml on VM.\n"));
535
+ if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
536
+ process.exit(1);
537
+ }
538
+ for (const c of composeChanges) {
539
+ const comp = Object.values(FOUNDATION_COMPONENTS).find(v => v.image === c.image);
540
+ console.log(chalk.green(` ✓ ${comp?.label || c.image}: ${c.newTag}`));
541
+ }
542
+ }
543
+
544
+ console.log(chalk.green(`\n ✓ ${affectedComponents.length} component(s) updated`));
545
+
546
+ // Offer to pull + restart
547
+ if (affectedComponents.length > 0) {
548
+ const { action } = await inquirer.prompt([{
549
+ type: "list",
550
+ name: "action",
551
+ message: "Pull images and restart?",
552
+ choices: [
553
+ { name: "Pull + restart affected services", value: "pull-restart" },
554
+ { name: "Pull only (no restart)", value: "pull" },
555
+ { name: "Skip (apply on next deploy)", value: "skip" },
556
+ ],
557
+ }]);
558
+
559
+ if (action === "pull-restart" || action === "pull") {
560
+ hint("Pulling images…");
561
+ await sshCmd(execa, ip, adminUser,
562
+ "cd /opt/foundation-compose && sudo docker compose pull --ignore-pull-failures 2>/dev/null; true",
563
+ 300000,
564
+ );
565
+ console.log(chalk.green(" ✓ Images pulled"));
566
+
567
+ if (action === "pull-restart") {
568
+ hint("Restarting services…");
569
+ await sshCmd(execa, ip, adminUser,
570
+ "cd /opt/foundation-compose && sudo docker compose up -d --remove-orphans",
571
+ 120000,
572
+ );
573
+ console.log(chalk.green(" ✓ Services restarted"));
574
+ }
575
+ } else {
576
+ hint(`Apply on next deploy: fops azure deploy${n}`);
577
+ }
578
+ }
579
+
580
+ if (state.knockSequence?.length) {
581
+ await closeKnock(ssh, { quiet: true });
582
+ }
583
+ console.log("");
584
+ }
585
+
586
+ // ── azureDeployVersion (non-interactive, CI-friendly) ────────────────────────
587
+
588
+ export async function azureDeployVersion(opts = {}) {
589
+ const execa = await lazyExeca();
590
+ const component = opts.component;
591
+ const tag = opts.tag;
592
+
593
+ if (!component || !tag) {
594
+ console.error(chalk.red("\n Usage: fops azure deploy version <component> <tag>"));
595
+ console.error(chalk.dim(" Components: " + Object.keys(FOUNDATION_COMPONENTS).join(", ")));
596
+ console.error(chalk.dim(" Example: fops azure deploy version backend v0.3.80\n"));
597
+ process.exit(1);
598
+ }
599
+
600
+ const compDef = FOUNDATION_COMPONENTS[component];
601
+ if (!compDef) {
602
+ console.error(chalk.red(`\n Unknown component: "${component}"`));
603
+ console.error(chalk.dim(" Available: " + Object.keys(FOUNDATION_COMPONENTS).join(", ") + "\n"));
604
+ process.exit(1);
605
+ }
606
+
607
+ const { activeVm, vms } = listVms();
608
+ const vmNames = Object.keys(vms);
609
+
610
+ // Determine targets: specific VM, all VMs, or AKS
611
+ const targets = [];
612
+ if (opts.vmName) {
613
+ if (!vms[opts.vmName]) {
614
+ console.error(chalk.red(`\n VM "${opts.vmName}" not tracked. Run: fops azure list\n`));
615
+ process.exit(1);
616
+ }
617
+ targets.push(opts.vmName);
618
+ } else if (opts.all) {
619
+ targets.push(...vmNames);
620
+ } else if (vmNames.length === 1) {
621
+ targets.push(vmNames[0]);
622
+ } else if (activeVm && vms[activeVm]) {
623
+ targets.push(activeVm);
624
+ } else if (vmNames.length > 0) {
625
+ targets.push(...vmNames);
626
+ }
627
+
628
+ banner(`Deploy ${compDef.label} → ${tag}`);
629
+
630
+ // Deploy to VMs
631
+ if (targets.length > 0) {
632
+ kvLine("VMs", DIM(targets.join(", ")));
633
+ kvLine("Image", DIM(`${compDef.image}:${tag}`));
634
+ console.log("");
635
+
636
+ const results = await Promise.allSettled(targets.map(async (name) => {
637
+ const vm = vms[name];
638
+ if (!vm?.publicIp) return { name, ok: false, reason: "no public IP" };
639
+
640
+ try {
641
+ await knockForVm(vm);
642
+ const ip = vm.publicIp;
643
+ const adminUser = DEFAULTS.adminUser;
644
+ const ssh = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
645
+
646
+ // Read current compose + env
647
+ const { stdout: composeContent } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
648
+ const { stdout: envContent } = await ssh("cat /opt/foundation-compose/.env 2>/dev/null || echo ''");
649
+ if (!composeContent?.trim()) throw new Error("Cannot read docker-compose.yaml");
650
+
651
+ const { versions } = parseComponentVersions(composeContent, envContent);
652
+ const v = versions[component];
653
+ if (!v) {
654
+ console.log(chalk.dim(` ${name}: ${compDef.label} not found in compose — skipping`));
655
+ return { name, ok: true, skipped: true };
656
+ }
657
+
658
+ if (v.effectiveTag === tag) {
659
+ console.log(OK(` ${name}: ${compDef.label} already at ${tag}`));
660
+ return { name, ok: true, unchanged: true };
661
+ }
662
+
663
+ // Apply version change: per-component env var in .env only (no compose edit for env refs)
664
+ if (v.isEnvRef) {
665
+ const varName = `IMAGE_TAG_${v.envKey}`;
666
+ const currentEnv = envContent || "";
667
+ let newEnv;
668
+ const re = new RegExp(`^\\s*${varName}\\s*=.*`, "m");
669
+ if (re.test(currentEnv)) {
670
+ newEnv = currentEnv.replace(re, `${varName}=${tag}`);
671
+ } else {
672
+ newEnv = currentEnv.trimEnd() + (currentEnv.trim() ? "\n" : "") + `${varName}=${tag}\n`;
673
+ }
674
+ const envB64 = Buffer.from(newEnv).toString("base64");
675
+ await sshCmd(execa, ip, adminUser,
676
+ `echo '${envB64}' | base64 -d > /opt/foundation-compose/.env`, 30000);
677
+ } else {
678
+ const updated = applyVersionChanges(composeContent, [
679
+ { image: v.image, oldPattern: v.rawTag, newTag: tag },
680
+ ]);
681
+ const composeB64 = Buffer.from(updated).toString("base64");
682
+ await sshCmd(execa, ip, adminUser,
683
+ `echo '${composeB64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`, 30000);
684
+ }
685
+
686
+ // Pull the specific image and restart the service
687
+ const serviceName = `foundation-${component}`;
688
+ hint(` ${name}: pulling ${compDef.image}:${tag}…`);
689
+ await sshCmd(execa, ip, adminUser,
690
+ `cd /opt/foundation-compose && sudo docker compose pull ${serviceName} 2>/dev/null; true`,
691
+ 300000);
692
+
693
+ if (!opts.noPull) {
694
+ hint(` ${name}: restarting ${serviceName}…`);
695
+ await sshCmd(execa, ip, adminUser,
696
+ `cd /opt/foundation-compose && sudo docker compose up -d --remove-orphans ${serviceName}`,
697
+ 120000);
698
+ }
699
+
700
+ console.log(OK(` ${name}: ${compDef.label} → ${tag} ✓`));
701
+ if (vm.knockSequence?.length) {
702
+ const sshFn = (cmd) => sshCmd(execa, ip, adminUser, cmd, 30000);
703
+ await closeKnock(sshFn, { quiet: true });
704
+ }
705
+ return { name, ok: true };
706
+ } catch (err) {
707
+ console.log(chalk.red(` ${name}: failed — ${err.message}`));
708
+ return { name, ok: false, reason: err.message };
709
+ }
710
+ }));
711
+
712
+ const succeeded = results.filter(r => r.status === "fulfilled" && r.value?.ok).length;
713
+ const failed = targets.length - succeeded;
714
+ console.log(`\n ${chalk.green(succeeded + " ok")}${failed ? chalk.red(", " + failed + " failed") : ""}`);
715
+ } else {
716
+ hint("No VMs tracked.");
717
+ }
718
+
719
+ // Deploy to AKS clusters if --aks or --all
720
+ if (opts.aks || opts.aksCluster) {
721
+ const aksTarget = opts.aksCluster || "";
722
+ hint(`\nUpdating AKS image tag…`);
723
+
724
+ try {
725
+ const { readAksClusters } = await import(resolveCliSrc("plugins/bundled/fops-plugin-azure/lib/azure-aks.js"));
726
+ const { clusters } = readAksClusters?.() || {};
727
+ const clusterNames = aksTarget ? [aksTarget] : Object.keys(clusters || {});
728
+
729
+ for (const cn of clusterNames) {
730
+ const fullImage = `${compDef.image}:${tag}`;
731
+ const namespace = "foundation";
732
+ const deployName = `foundation-${component}`;
733
+
734
+ const { exitCode, stderr } = await execa("kubectl", [
735
+ "--context", cn,
736
+ "set", "image",
737
+ `deployment/${deployName}`,
738
+ `${component}=${fullImage}`,
739
+ "-n", namespace,
740
+ ], { reject: false, timeout: 30000 });
741
+
742
+ if (exitCode === 0) {
743
+ console.log(OK(` ${cn}: ${deployName} → ${tag}`));
744
+ } else {
745
+ console.log(WARN(` ${cn}: ${(stderr || "").split("\n")[0]}`));
746
+ }
747
+ }
748
+ } catch (err) {
749
+ console.log(WARN(` AKS deploy failed: ${err.message}`));
750
+ }
751
+ }
752
+
753
+ console.log("");
754
+ }