@flui-cloud/cli 0.0.1 → 0.2.0

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 (108) hide show
  1. package/lib/cli/src/commands/app/list.d.ts +3 -0
  2. package/lib/cli/src/commands/app/list.js +72 -18
  3. package/lib/cli/src/commands/app/status.d.ts +1 -0
  4. package/lib/cli/src/commands/app/status.js +27 -2
  5. package/lib/cli/src/commands/cluster/destroy.d.ts +1 -1
  6. package/lib/cli/src/commands/cluster/destroy.js +2 -2
  7. package/lib/cli/src/commands/deploy.d.ts +3 -0
  8. package/lib/cli/src/commands/deploy.js +19 -0
  9. package/lib/cli/src/commands/dev/creds.d.ts +0 -1
  10. package/lib/cli/src/commands/dev/creds.js +6 -27
  11. package/lib/cli/src/commands/dev/tunnel.js +8 -8
  12. package/lib/cli/src/commands/env/capacity.js +4 -4
  13. package/lib/cli/src/commands/env/create.d.ts +4 -1
  14. package/lib/cli/src/commands/env/create.js +78 -52
  15. package/lib/cli/src/commands/env/credentials.js +12 -12
  16. package/lib/cli/src/commands/env/destroy.d.ts +2 -1
  17. package/lib/cli/src/commands/env/destroy.js +45 -28
  18. package/lib/cli/src/commands/env/diag-ca.js +5 -5
  19. package/lib/cli/src/commands/env/export-config.d.ts +0 -17
  20. package/lib/cli/src/commands/env/export-config.js +50 -47
  21. package/lib/cli/src/commands/env/force-ready.d.ts +1 -1
  22. package/lib/cli/src/commands/env/force-ready.js +8 -8
  23. package/lib/cli/src/commands/env/inspect.js +5 -5
  24. package/lib/cli/src/commands/env/refresh-kubeconfig.js +4 -4
  25. package/lib/cli/src/commands/env/repair-ssh-ca.js +4 -4
  26. package/lib/cli/src/commands/env/repair-storage.d.ts +9 -0
  27. package/lib/cli/src/commands/env/repair-storage.js +82 -0
  28. package/lib/cli/src/commands/env/restart.d.ts +1 -1
  29. package/lib/cli/src/commands/env/restart.js +9 -9
  30. package/lib/cli/src/commands/env/scale-master.js +4 -4
  31. package/lib/cli/src/commands/env/scale-node.js +4 -4
  32. package/lib/cli/src/commands/env/set-master-protection.d.ts +16 -0
  33. package/lib/cli/src/commands/env/set-master-protection.js +120 -0
  34. package/lib/cli/src/commands/env/status.d.ts +1 -1
  35. package/lib/cli/src/commands/env/status.js +10 -10
  36. package/lib/cli/src/commands/env/stop.d.ts +1 -1
  37. package/lib/cli/src/commands/env/stop.js +8 -8
  38. package/lib/cli/src/commands/env/storage-expand.js +4 -4
  39. package/lib/cli/src/commands/env/storage.d.ts +1 -1
  40. package/lib/cli/src/commands/env/storage.js +5 -5
  41. package/lib/cli/src/commands/env/sync.js +5 -5
  42. package/lib/cli/src/commands/env/uncordon.js +4 -4
  43. package/lib/cli/src/commands/env/update-firewall.d.ts +13 -1
  44. package/lib/cli/src/commands/env/update-firewall.js +232 -126
  45. package/lib/cli/src/commands/integration/connect.d.ts +1 -0
  46. package/lib/cli/src/commands/integration/connect.js +19 -1
  47. package/lib/cli/src/commands/integration/reset.d.ts +13 -0
  48. package/lib/cli/src/commands/integration/reset.js +95 -0
  49. package/lib/cli/src/commands/integration/setup.d.ts +18 -0
  50. package/lib/cli/src/commands/integration/setup.js +320 -0
  51. package/lib/cli/src/commands/integration/status.d.ts +9 -0
  52. package/lib/cli/src/commands/integration/status.js +117 -0
  53. package/lib/cli/src/commands/node/list.d.ts +1 -0
  54. package/lib/cli/src/commands/node/list.js +19 -2
  55. package/lib/cli/src/commands/server-types/list.d.ts +3 -0
  56. package/lib/cli/src/commands/server-types/list.js +84 -0
  57. package/lib/cli/src/commands/ssh.js +5 -5
  58. package/lib/cli/src/commands/version.d.ts +18 -0
  59. package/lib/cli/src/commands/version.js +100 -0
  60. package/lib/cli/src/config/bootstrap.config.d.ts +10 -1
  61. package/lib/cli/src/config/bootstrap.config.js +24 -4
  62. package/lib/cli/src/config/preferences-schema.js +5 -5
  63. package/lib/cli/src/config/release-override.d.ts +43 -0
  64. package/lib/cli/src/config/release-override.js +203 -0
  65. package/lib/cli/src/config/release.config.d.ts +31 -0
  66. package/lib/cli/src/config/release.config.js +38 -0
  67. package/lib/cli/src/lib/prompts.d.ts +1 -6
  68. package/lib/cli/src/lib/prompts.js +33 -13
  69. package/lib/cli/src/lib/services/cli-app.service.d.ts +33 -0
  70. package/lib/cli/src/lib/services/cli-app.service.js +9 -0
  71. package/lib/cli/src/lib/services/reconciliation.service.js +1 -1
  72. package/lib/cli/src/lib/templates/firewall-rules.d.ts +2 -2
  73. package/lib/cli/src/lib/templates/firewall-rules.js +3 -3
  74. package/lib/cli/src/modules/cli-infrastructure.module.js +3 -3
  75. package/lib/cli/src/services/cli-cluster-creator.service.js +31 -6
  76. package/lib/cli/src/services/cli-clusters.service.d.ts +3 -3
  77. package/lib/cli/src/services/cli-clusters.service.js +57 -34
  78. package/lib/cli/src/services/cli-control-cluster.service.d.ts +129 -0
  79. package/lib/cli/src/services/cli-control-cluster.service.js +545 -0
  80. package/lib/cli/src/services/cli-endpoint-resolver.service.d.ts +1 -0
  81. package/lib/cli/src/services/cli-endpoint-resolver.service.js +25 -11
  82. package/lib/cli/src/services/cli-k3s-script.service.d.ts +8 -1
  83. package/lib/cli/src/services/cli-k3s-script.service.js +14 -6
  84. package/lib/src/config/release.config.d.ts +28 -0
  85. package/lib/src/config/release.config.js +35 -0
  86. package/lib/src/modules/applications/entities/application.entity.d.ts +13 -20
  87. package/lib/src/modules/applications/entities/application.entity.js +12 -0
  88. package/lib/src/modules/applications/enums/application-exposure.enum.d.ts +2 -1
  89. package/lib/src/modules/applications/enums/application-exposure.enum.js +1 -0
  90. package/lib/src/modules/applications/interfaces/source-config.interface.d.ts +1 -0
  91. package/lib/src/modules/infrastructure/clusters/entities/cluster.entity.d.ts +8 -2
  92. package/lib/src/modules/infrastructure/clusters/entities/cluster.entity.js +16 -1
  93. package/lib/src/modules/infrastructure/clusters/services/cluster-node-scaling.service.js +2 -2
  94. package/lib/src/modules/infrastructure/firewalls/templates/firewall-rules.template.d.ts +3 -2
  95. package/lib/src/modules/infrastructure/firewalls/templates/firewall-rules.template.js +11 -4
  96. package/lib/src/modules/infrastructure/shared/services/kubernetes.service.d.ts +26 -0
  97. package/lib/src/modules/infrastructure/shared/services/kubernetes.service.js +105 -8
  98. package/lib/src/modules/management/entities/provider-capabilities.entity.d.ts +2 -0
  99. package/lib/src/modules/providers/implementations/contabo/contabo-capabilities.service.js +2 -0
  100. package/lib/src/modules/providers/implementations/hetzner/hetzner-capabilities.service.js +3 -6
  101. package/lib/src/modules/providers/implementations/scaleway/scaleway-capabilities.service.js +2 -1
  102. package/lib/src/modules/providers/implementations/scaleway/scaleway-firewall.service.js +3 -1
  103. package/lib/src/modules/providers/implementations/scaleway/scaleway-provider.service.js +3 -1
  104. package/lib/src/modules/providers/interfaces/provider-capabilities.interface.d.ts +0 -2
  105. package/lib/src/modules/providers/services/hetzner-firewall.service.d.ts +1 -1
  106. package/lib/src/modules/providers/services/hetzner-firewall.service.js +2 -1
  107. package/oclif.manifest.json +1201 -854
  108. package/package.json +2 -2
@@ -0,0 +1,545 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
19
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
23
+ };
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ var __metadata = (this && this.__metadata) || function (k, v) {
42
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
43
+ };
44
+ var CliControlClusterService_1;
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.CliControlClusterService = void 0;
47
+ const common_1 = require("@nestjs/common");
48
+ const cluster_entity_1 = require("../../../src/modules/infrastructure/clusters/entities/cluster.entity");
49
+ const cli_cluster_repository_1 = require("../lib/repositories/cli-cluster.repository");
50
+ const release_override_1 = require("../config/release-override");
51
+ const nip_base_domain_util_1 = require("../lib/nip-base-domain.util");
52
+ const cli_node_repository_1 = require("../lib/repositories/cli-node.repository");
53
+ const cli_clusters_service_1 = require("./cli-clusters.service");
54
+ const cli_operation_repository_1 = require("../lib/repositories/cli-operation.repository");
55
+ const cli_ssh_service_1 = require("./cli-ssh.service");
56
+ const infrastructure_operations_entity_1 = require("../../../src/modules/infrastructure/servers/entities/infrastructure-operations.entity");
57
+ const net = __importStar(require("node:net"));
58
+ const https = __importStar(require("node:https"));
59
+ /**
60
+ * CLI Control Cluster Service
61
+ *
62
+ * Simplified version of ObservabilityClusterService for CLI usage.
63
+ * Uses file-based repositories instead of TypeORM.
64
+ */
65
+ let CliControlClusterService = CliControlClusterService_1 = class CliControlClusterService {
66
+ constructor(clusterRepository, nodeRepository, clustersService, operationRepository, sshService) {
67
+ this.clusterRepository = clusterRepository;
68
+ this.nodeRepository = nodeRepository;
69
+ this.clustersService = clustersService;
70
+ this.operationRepository = operationRepository;
71
+ this.sshService = sshService;
72
+ this.logger = new common_1.Logger(CliControlClusterService_1.name);
73
+ }
74
+ /**
75
+ * Get the control cluster
76
+ */
77
+ async getControlCluster() {
78
+ const cluster = await this.clusterRepository.findOne({
79
+ where: {
80
+ metadata: { isObservabilityCluster: true },
81
+ },
82
+ });
83
+ if (!cluster) {
84
+ return null;
85
+ }
86
+ // Manually load nodes from separate repository
87
+ const nodes = await this.nodeRepository.find({
88
+ where: { clusterId: cluster.id },
89
+ });
90
+ cluster.nodes = nodes;
91
+ return cluster;
92
+ }
93
+ /**
94
+ * Check if control cluster exists
95
+ */
96
+ async hasControlCluster() {
97
+ const cluster = await this.getControlCluster();
98
+ return cluster !== null;
99
+ }
100
+ /**
101
+ * Get observability service endpoints
102
+ */
103
+ async getObservabilityEndpoints(clusterId) {
104
+ const cluster = await this.clusterRepository.findOne({
105
+ where: { id: clusterId },
106
+ });
107
+ if (!cluster?.masterIpAddress) {
108
+ return {};
109
+ }
110
+ const ip = cluster.masterIpAddress;
111
+ const baseDomain = (0, nip_base_domain_util_1.buildNipBaseDomain)(ip, cluster.nipHostnameToken);
112
+ return {
113
+ prometheus: `cluster-internal (kubectl port-forward -n flui-system svc/prometheus 9090)`,
114
+ grafana: `cluster-internal (kubectl port-forward -n flui-system svc/grafana 3000)`,
115
+ loki: `cluster-internal (kubectl port-forward -n flui-system svc/loki 3100)`,
116
+ postgres: `cluster-internal (kubectl port-forward -n flui-system svc/postgres 5432)`,
117
+ redis: `cluster-internal (kubectl port-forward -n flui-system svc/redis 6379)`,
118
+ fluiApi: `https://api.${baseDomain}`,
119
+ fluiWeb: `https://app.${baseDomain}`,
120
+ };
121
+ }
122
+ /**
123
+ * Create control cluster
124
+ */
125
+ async createControlCluster(provider, region, nodeSize, workerCount = 0, firewallId, sourceCidrs, authMode = 'local', envVnet, adminEmail, acmeStaging, diskSizeGb, options) {
126
+ // Pin the install to the CLI release (bootstrap ref + image tags), unless
127
+ // --latest opted into mobile dev tags. Recorded on the cluster so the
128
+ // installed version stays queryable after creation.
129
+ const useLatest = options?.useLatest ?? false;
130
+ const release = (0, release_override_1.getEffectiveRelease)(useLatest);
131
+ const createDto = {
132
+ name: 'control-cluster',
133
+ provider: provider,
134
+ region,
135
+ nodeSize,
136
+ workerCount,
137
+ image: 'ubuntu-24.04',
138
+ diskSizeGb,
139
+ sharedStorageEnabled: options?.sharedStorageEnabled,
140
+ sharedStorageVolumeSizeGb: options?.sharedStorageVolumeSizeGb,
141
+ metadata: {
142
+ isObservabilityCluster: true,
143
+ firewallId,
144
+ sourceCidrs,
145
+ authMode,
146
+ envVnet,
147
+ adminEmail,
148
+ acmeStaging,
149
+ useLatest,
150
+ platformVersion: release.version,
151
+ componentVersions: release.images,
152
+ bootstrapRef: release.bootstrapRef,
153
+ },
154
+ };
155
+ const { cluster } = await this.clustersService.create(createDto);
156
+ return cluster.id;
157
+ }
158
+ /**
159
+ * Deploy observability stack (Prometheus, Grafana, Loki)
160
+ */
161
+ async deployObservabilityStack(clusterId) {
162
+ // TODO: Implement observability stack deployment
163
+ this.logger.warn('Observability stack deployment not implemented in CLI mode');
164
+ }
165
+ /**
166
+ * Delete control cluster
167
+ */
168
+ async deleteControlCluster() {
169
+ // Local clusters.json can accumulate stale observability entries (e.g. a
170
+ // previous destroy died mid-way, or a create crashed after persisting).
171
+ // findOne would only catch one — so we iterate and purge all matches.
172
+ const clusters = await this.clusterRepository.find({
173
+ where: { metadata: { isObservabilityCluster: true } },
174
+ });
175
+ if (clusters.length === 0) {
176
+ throw new Error('No control cluster found');
177
+ }
178
+ if (clusters.length > 1) {
179
+ this.logger.warn(`Found ${clusters.length} control cluster records — removing all to clear stale state`);
180
+ }
181
+ let lastError = null;
182
+ for (const cluster of clusters) {
183
+ try {
184
+ await this.clustersService.remove(cluster.id);
185
+ }
186
+ catch (error) {
187
+ lastError = error;
188
+ this.logger.error(`Failed to remove cluster ${cluster.id} (${cluster.name}): ${error.message}`);
189
+ }
190
+ }
191
+ if (lastError)
192
+ throw lastError;
193
+ }
194
+ /**
195
+ * Get cluster operation by cluster ID
196
+ */
197
+ async getClusterOperation(clusterId) {
198
+ return this.operationRepository.findOne({
199
+ where: {
200
+ resourceId: clusterId,
201
+ resourceType: 'cluster',
202
+ },
203
+ order: {
204
+ createdAt: 'DESC',
205
+ },
206
+ });
207
+ }
208
+ /**
209
+ * Wait for cluster to be ready by polling operation status
210
+ * @param clusterId Cluster ID to wait for
211
+ * @param timeoutMs Timeout in milliseconds (default: 10 minutes)
212
+ * @param pollIntervalMs Polling interval in milliseconds (default: 10 seconds)
213
+ * @returns Promise that resolves when cluster is ready
214
+ * @throws Error if timeout is reached or operation fails
215
+ */
216
+ async waitForClusterReady(clusterId, timeoutMs = 600000, pollIntervalMs = 10000) {
217
+ const startTime = Date.now();
218
+ const maxAttempts = Math.ceil(timeoutMs / pollIntervalMs);
219
+ this.logger.log(`Waiting for cluster ${clusterId} to be ready (timeout: ${timeoutMs}ms, poll interval: ${pollIntervalMs}ms)`);
220
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
221
+ const operation = await this.getClusterOperation(clusterId);
222
+ if (!operation) {
223
+ throw new Error(`No operation found for cluster ${clusterId}. Cluster may not exist.`);
224
+ }
225
+ this.logger.debug(`Poll attempt ${attempt}/${maxAttempts}: Operation status = ${operation.status}, progress = ${operation.progress}%`);
226
+ // Check if operation completed successfully
227
+ if (operation.status === infrastructure_operations_entity_1.OperationStatus.COMPLETED) {
228
+ this.logger.log(`Cluster ${clusterId} is ready (took ${Date.now() - startTime}ms)`);
229
+ return;
230
+ }
231
+ // Check if operation failed
232
+ if (operation.status === infrastructure_operations_entity_1.OperationStatus.FAILED) {
233
+ const errorMsg = operation.metadata?.error || 'Unknown error during cluster creation';
234
+ throw new Error(`Cluster creation failed: ${errorMsg}`);
235
+ }
236
+ // Check timeout
237
+ if (Date.now() - startTime >= timeoutMs) {
238
+ throw new Error(`Timeout waiting for cluster ${clusterId} to be ready after ${timeoutMs}ms. Current status: ${operation.status}, progress: ${operation.progress}%`);
239
+ }
240
+ // Wait before next poll (unless it's the last attempt)
241
+ if (attempt < maxAttempts) {
242
+ await this.sleep(pollIntervalMs);
243
+ }
244
+ }
245
+ throw new Error(`Failed to confirm cluster readiness after ${maxAttempts} attempts`);
246
+ }
247
+ /**
248
+ * Wait for master node IP address to become available
249
+ * Polls the cluster repository until masterIpAddress is populated
250
+ */
251
+ async waitForMasterIp(clusterId, timeoutMs = 600000, pollIntervalMs = 5000) {
252
+ const startTime = Date.now();
253
+ this.logger.log(`Waiting for master IP of cluster ${clusterId} (timeout: ${timeoutMs}ms)`);
254
+ while (Date.now() - startTime < timeoutMs) {
255
+ const cluster = await this.clusterRepository.findOne({
256
+ where: { id: clusterId },
257
+ });
258
+ if (!cluster) {
259
+ throw new Error(`Cluster ${clusterId} not found. It may have been deleted.`);
260
+ }
261
+ if (cluster.status === cluster_entity_1.ClusterStatus.ERROR) {
262
+ throw new Error('Cluster creation failed before master IP was assigned.');
263
+ }
264
+ if (cluster.masterIpAddress) {
265
+ this.logger.log(`Master IP available: ${cluster.masterIpAddress}`);
266
+ return cluster.masterIpAddress;
267
+ }
268
+ await this.sleep(pollIntervalMs);
269
+ }
270
+ throw new Error(`Timeout waiting for master IP address after ${timeoutMs}ms`);
271
+ }
272
+ /**
273
+ * Wait for TCP port to become reachable on a host
274
+ */
275
+ async waitForPortReady(host, port = 22, timeoutMs = 600000, pollIntervalMs = 5000) {
276
+ const startTime = Date.now();
277
+ this.logger.log(`Waiting for port ${port} on ${host} (timeout: ${timeoutMs}ms)`);
278
+ while (Date.now() - startTime < timeoutMs) {
279
+ const isReachable = await this.checkTcpPort(host, port);
280
+ if (isReachable) {
281
+ this.logger.log(`Port ${port} is reachable on ${host}`);
282
+ return;
283
+ }
284
+ await this.sleep(pollIntervalMs);
285
+ }
286
+ throw new Error(`Timeout waiting for port ${port} on ${host} after ${timeoutMs}ms`);
287
+ }
288
+ /**
289
+ * Wait for SSH to be fully ready (CA enrolled + cert auth working)
290
+ * Attempts an actual SSH command with retry until it succeeds.
291
+ * This is needed because TCP port 22 can be open before cloud-init
292
+ * has finished configuring the CA for certificate authentication.
293
+ *
294
+ * @param sshTestFn Function that attempts an SSH command and throws on failure
295
+ */
296
+ async waitForSshAuth(sshTestFn, timeoutMs = 600000, pollIntervalMs = 10000) {
297
+ const startTime = Date.now();
298
+ this.logger.log(`Waiting for SSH authentication to be ready (timeout: ${timeoutMs}ms)`);
299
+ let lastError;
300
+ while (Date.now() - startTime < timeoutMs) {
301
+ try {
302
+ await sshTestFn();
303
+ this.logger.log('SSH authentication is ready');
304
+ return;
305
+ }
306
+ catch (error) {
307
+ lastError = error;
308
+ }
309
+ await this.sleep(pollIntervalMs);
310
+ }
311
+ if (lastError) {
312
+ this.logger.error(`SSH exec failed: ${lastError.message}`);
313
+ }
314
+ throw new Error(`Timeout waiting for SSH authentication after ${timeoutMs}ms`);
315
+ }
316
+ /**
317
+ * Poll operation status in background and call onReady when COMPLETED.
318
+ * Returns a stop function and a done promise that resolves when the
319
+ * poller finishes (after onReady/onFailed callback completes or stop() is called).
320
+ */
321
+ pollOperationUntilReady(clusterId, onReady, onFailed, pollIntervalMs = 10000) {
322
+ let stopped = false;
323
+ const poll = async () => {
324
+ while (!stopped) {
325
+ try {
326
+ const operation = await this.getClusterOperation(clusterId);
327
+ if (!operation) {
328
+ this.logger.warn(`No operation found for cluster ${clusterId} during background poll`);
329
+ await this.sleep(pollIntervalMs);
330
+ continue;
331
+ }
332
+ if (operation.status === infrastructure_operations_entity_1.OperationStatus.COMPLETED) {
333
+ this.logger.log(`Background poll: cluster ${clusterId} is READY`);
334
+ if (!stopped) {
335
+ await onReady();
336
+ }
337
+ return;
338
+ }
339
+ if (operation.status === infrastructure_operations_entity_1.OperationStatus.FAILED) {
340
+ const errorMsg = operation.metadata?.error ||
341
+ 'Unknown error during cluster creation';
342
+ this.logger.error(`Background poll: cluster creation failed: ${errorMsg}`);
343
+ if (onFailed && !stopped) {
344
+ onFailed(errorMsg);
345
+ }
346
+ return;
347
+ }
348
+ }
349
+ catch (error) {
350
+ this.logger.debug(`Background poll error: ${error.message}`);
351
+ }
352
+ await this.sleep(pollIntervalMs);
353
+ }
354
+ };
355
+ const done = poll().catch((error) => {
356
+ this.logger.error(`Background poller crashed: ${error.message}`);
357
+ });
358
+ return {
359
+ stop: () => {
360
+ stopped = true;
361
+ },
362
+ done,
363
+ };
364
+ }
365
+ /**
366
+ * Check observability services health via HTTP endpoints
367
+ * @param masterIp Master node IP address
368
+ * @returns Object with service health status
369
+ */
370
+ async checkObservabilityServices(masterIp, _nipHostnameToken) {
371
+ // Match by (unique) workload name only — the observability stack lives in
372
+ // `flui-control` on new installs and `flui-observability` on legacy ones.
373
+ const lookups = [
374
+ { ns: 'control', name: 'vmsingle', key: 'prometheus' },
375
+ { ns: 'control', name: 'grafana', key: 'grafana' },
376
+ { ns: 'control', name: 'loki', key: 'loki' },
377
+ { ns: 'flui-system', name: 'postgres', key: 'postgres' },
378
+ { ns: 'flui-system', name: 'redis', key: 'redis' },
379
+ { ns: 'flui-system', name: 'flui-api', key: 'fluiApi' },
380
+ { ns: 'flui-system', name: 'flui-web', key: 'fluiWeb' },
381
+ ];
382
+ const result = {
383
+ prometheus: 'unreachable',
384
+ grafana: 'unreachable',
385
+ loki: 'unreachable',
386
+ postgres: 'unreachable',
387
+ redis: 'unreachable',
388
+ fluiApi: 'unreachable',
389
+ fluiWeb: 'unreachable',
390
+ };
391
+ try {
392
+ const combined = `(kubectl -n flui-control get deploy,statefulset -o json 2>/dev/null || echo '{"items":[]}') ; ` +
393
+ `echo '---FLUI-SEP---' ; ` +
394
+ `(kubectl -n flui-observability get deploy,statefulset -o json 2>/dev/null || echo '{"items":[]}') ; ` +
395
+ `echo '---FLUI-SEP---' ; ` +
396
+ `(kubectl -n flui-system get deploy,statefulset -o json 2>/dev/null || echo '{"items":[]}')`;
397
+ const raw = await this.sshService.sshExec(masterIp, combined);
398
+ const [controlRaw, legacyRaw, sysRaw] = raw.split('---FLUI-SEP---');
399
+ const collect = (json) => {
400
+ try {
401
+ const parsed = JSON.parse(json);
402
+ return parsed.items ?? [];
403
+ }
404
+ catch {
405
+ return [];
406
+ }
407
+ };
408
+ const items = [
409
+ ...collect(controlRaw || ''),
410
+ ...collect(legacyRaw || ''),
411
+ ...collect(sysRaw || ''),
412
+ ];
413
+ for (const lookup of lookups) {
414
+ const item = items.find((i) => lookup.ns === 'control'
415
+ ? i.metadata?.name === lookup.name
416
+ : i.metadata?.name === lookup.name &&
417
+ i.metadata?.namespace === lookup.ns);
418
+ const replicas = item?.status?.replicas ?? 0;
419
+ const ready = item?.status?.readyReplicas ?? 0;
420
+ result[lookup.key] =
421
+ replicas > 0 && ready === replicas ? 'healthy' : 'unreachable';
422
+ }
423
+ }
424
+ catch {
425
+ // On SSH failure, leave defaults (all unreachable).
426
+ }
427
+ return result;
428
+ }
429
+ /**
430
+ * Check if a TCP port is reachable on a host
431
+ */
432
+ checkTcpPort(host, port, timeout = 5000) {
433
+ return new Promise((resolve) => {
434
+ const socket = new net.Socket();
435
+ const timer = setTimeout(() => {
436
+ socket.destroy();
437
+ resolve(false);
438
+ }, timeout);
439
+ socket.on('connect', () => {
440
+ clearTimeout(timer);
441
+ socket.destroy();
442
+ resolve(true);
443
+ });
444
+ socket.on('error', () => {
445
+ clearTimeout(timer);
446
+ resolve(false);
447
+ });
448
+ socket.connect(port, host);
449
+ });
450
+ }
451
+ /**
452
+ * Sleep helper
453
+ */
454
+ sleep(ms) {
455
+ return new Promise((resolve) => setTimeout(resolve, ms));
456
+ }
457
+ async waitForValidTls(url, timeoutMs = 300_000, intervalMs = 15_000, acmeStaging = false) {
458
+ // Production: rely on Node's default chain validation (LE prod root is in the OS trust store).
459
+ // Staging: chain is signed by LE staging fake CA (not in OS trust store), so we connect with
460
+ // rejectUnauthorized=false but then inspect the served cert issuer to ensure it's a real LE
461
+ // cert and not Traefik's self-signed default.
462
+ const agent = acmeStaging
463
+ ? new https.Agent({ rejectUnauthorized: false })
464
+ : undefined;
465
+ const deadline = Date.now() + timeoutMs;
466
+ while (Date.now() < deadline) {
467
+ const valid = await new Promise((resolve) => {
468
+ const req = https.request(url, { method: 'HEAD', agent }, (res) => {
469
+ if (!acmeStaging) {
470
+ resolve(true);
471
+ return;
472
+ }
473
+ const cert = res.socket.getPeerCertificate?.();
474
+ const issuerO = cert?.issuer?.O ?? '';
475
+ resolve(issuerO.includes("Let's Encrypt"));
476
+ });
477
+ req.on('error', () => resolve(false));
478
+ req.setTimeout(10_000, () => {
479
+ req.destroy();
480
+ resolve(false);
481
+ });
482
+ req.end();
483
+ });
484
+ if (valid)
485
+ return true;
486
+ await this.sleep(intervalMs);
487
+ }
488
+ return false;
489
+ }
490
+ async waitForOidcReady(apiBaseUrl, timeoutMs = 300_000, intervalMs = 10_000, acmeStaging = false) {
491
+ const url = `${apiBaseUrl.replace(/\/$/, '')}/api/v1/health/oidc`;
492
+ const agent = acmeStaging
493
+ ? new https.Agent({ rejectUnauthorized: false })
494
+ : undefined;
495
+ const deadline = Date.now() + timeoutMs;
496
+ while (Date.now() < deadline) {
497
+ const ready = await new Promise((resolve) => {
498
+ const req = https.get(url, { timeout: 10_000, agent }, (res) => {
499
+ if (acmeStaging) {
500
+ const cert = res.socket.getPeerCertificate?.();
501
+ const issuerO = cert?.issuer?.O ?? '';
502
+ if (!issuerO.includes("Let's Encrypt")) {
503
+ res.resume();
504
+ resolve(false);
505
+ return;
506
+ }
507
+ }
508
+ if (res.statusCode !== 200) {
509
+ res.resume();
510
+ resolve(false);
511
+ return;
512
+ }
513
+ let body = '';
514
+ res.on('data', (chunk) => (body += chunk));
515
+ res.on('end', () => {
516
+ try {
517
+ resolve(!!JSON.parse(body).ready);
518
+ }
519
+ catch {
520
+ resolve(false);
521
+ }
522
+ });
523
+ });
524
+ req.on('error', () => resolve(false));
525
+ req.on('timeout', () => {
526
+ req.destroy();
527
+ resolve(false);
528
+ });
529
+ });
530
+ if (ready)
531
+ return true;
532
+ await this.sleep(intervalMs);
533
+ }
534
+ return false;
535
+ }
536
+ };
537
+ exports.CliControlClusterService = CliControlClusterService;
538
+ exports.CliControlClusterService = CliControlClusterService = CliControlClusterService_1 = __decorate([
539
+ (0, common_1.Injectable)(),
540
+ __metadata("design:paramtypes", [cli_cluster_repository_1.CliClusterRepository,
541
+ cli_node_repository_1.CliNodeRepository,
542
+ cli_clusters_service_1.CliClustersService,
543
+ cli_operation_repository_1.CliOperationRepository,
544
+ cli_ssh_service_1.CliSshService])
545
+ ], CliControlClusterService);
@@ -19,6 +19,7 @@ export interface SystemEndpoints {
19
19
  oidcAudience: string;
20
20
  /** Public OIDC client ID consumed by the dashboard (flui-web-config ConfigMap). */
21
21
  oidcClientId: string;
22
+ oidcCliClientId: string;
22
23
  }
23
24
  export declare class CliEndpointResolverService {
24
25
  private readonly sshService;
@@ -61,27 +61,33 @@ let CliEndpointResolverService = CliEndpointResolverService_1 = class CliEndpoin
61
61
  const snapshot = await this.fetchSnapshot(masterIp);
62
62
  const configMapData = snapshot.configmap?.data ?? {};
63
63
  const secretData = snapshot.secret?.data ?? {};
64
- const authMode = configMapData['AUTH_MODE'] ?? 'unknown';
65
- const oidcIssuer = configMapData['OIDC_ISSUER'] ?? '';
66
- const oidcJwksUri = configMapData['OIDC_JWKS_URI'] ?? '';
67
- const oidcAudience = secretData['OIDC_AUDIENCE']
64
+ const live = snapshot.authConfig ?? {};
65
+ const pick = (...vals) => vals.find((v) => typeof v === 'string' && v.trim() !== '')?.trim() ?? '';
66
+ const frozenAudience = secretData['OIDC_AUDIENCE']
68
67
  ? Buffer.from(secretData['OIDC_AUDIENCE'], 'base64').toString('utf-8')
69
68
  : '';
70
- // Dashboard public client_id lives inside the flui-web-config ConfigMap's
71
- // `config.json` data key — written by OidcBootstrapService.patchWebConfigMap.
72
- let oidcClientId = '';
69
+ let frozenClientId = '';
73
70
  const webConfigJson = snapshot.webConfigMap?.data?.['config.json'];
74
71
  if (webConfigJson) {
75
72
  try {
76
73
  const parsed = JSON.parse(webConfigJson);
77
74
  if (typeof parsed.oidcClientId === 'string') {
78
- oidcClientId = parsed.oidcClientId;
75
+ frozenClientId = parsed.oidcClientId;
79
76
  }
80
77
  }
81
78
  catch (err) {
82
79
  this.logger.warn(`flui-web-config/config.json is not valid JSON: ${err.message}`);
83
80
  }
84
81
  }
82
+ // Prefer the live values; fall back to the frozen ConfigMap/Secret only if
83
+ // the API was unreachable (k3s auto-deploy resets the frozen ones).
84
+ const authMode = pick(live.authMode, configMapData['AUTH_MODE'], 'unknown');
85
+ const oidcIssuer = pick(live.issuer, configMapData['OIDC_ISSUER']);
86
+ const oidcJwksUri = configMapData['OIDC_JWKS_URI'] ?? '';
87
+ const oidcCliClientId = pick(live.cliClientId, configMapData['OIDC_CLI_CLIENT_ID']);
88
+ const oidcClientId = pick(live.clientId, frozenClientId);
89
+ // audience = web client id
90
+ const oidcAudience = pick(live.clientId, frozenAudience);
85
91
  const endpoints = {};
86
92
  for (const key of Object.keys(SYSTEM_APPS)) {
87
93
  endpoints[key] = this.resolveApp(key, SYSTEM_APPS[key], snapshot.ingresses.items ?? [], snapshot.ingressRoutes.items ?? [], masterIp, nipHostnameToken);
@@ -93,6 +99,7 @@ let CliEndpointResolverService = CliEndpointResolverService_1 = class CliEndpoin
93
99
  oidcJwksUri,
94
100
  oidcAudience,
95
101
  oidcClientId,
102
+ oidcCliClientId,
96
103
  };
97
104
  }
98
105
  async fetchSnapshot(masterIp) {
@@ -102,7 +109,10 @@ let CliEndpointResolverService = CliEndpointResolverService_1 = class CliEndpoin
102
109
  `CM=$(kubectl get configmap flui-api-config -n flui-system -o json 2>/dev/null || echo '{}')`,
103
110
  `SEC=$(kubectl get secret flui-secrets -n flui-system -o json 2>/dev/null || echo '{}')`,
104
111
  `WCM=$(kubectl get configmap flui-web-config -n flui-system -o json 2>/dev/null || echo '{}')`,
105
- `printf '{"ingresses":%s,"ingressRoutes":%s,"configmap":%s,"secret":%s,"webConfigMap":%s}' "$ING" "$IR" "$CM" "$SEC" "$WCM"`,
112
+ `AIP=$(kubectl get svc flui-api -n flui-system -o jsonpath='{.spec.clusterIP}' 2>/dev/null)`,
113
+ `APT=$(kubectl get svc flui-api -n flui-system -o jsonpath='{.spec.ports[0].port}' 2>/dev/null)`,
114
+ `AC=$(curl -s --max-time 5 "http://$AIP:$APT/api/v1/auth/config" 2>/dev/null || echo '{}')`,
115
+ `printf '{"ingresses":%s,"ingressRoutes":%s,"configmap":%s,"secret":%s,"webConfigMap":%s,"authConfig":%s}' "$ING" "$IR" "$CM" "$SEC" "$WCM" "$AC"`,
106
116
  ].join('; ');
107
117
  const output = await this.sshService.sshExec(masterIp, command);
108
118
  try {
@@ -114,8 +124,12 @@ let CliEndpointResolverService = CliEndpointResolverService_1 = class CliEndpoin
114
124
  }
115
125
  }
116
126
  resolveApp(key, spec, ingresses, ingressRoutes, masterIp, nipHostnameToken) {
117
- const match = this.findIngressRouteMatch(spec, ingressRoutes) ??
118
- this.findIngressMatch(spec, ingresses);
127
+ // Prefer the k8s native Ingress over Traefik IngressRoute when both exist:
128
+ // `configure-system-ingress` writes Ingress with the user-chosen domain
129
+ // (e.g. *.flui.cloud), while the legacy IngressRoute may still carry
130
+ // the nip.io hostname seeded at cluster bootstrap.
131
+ const match = this.findIngressMatch(spec, ingresses) ??
132
+ this.findIngressRouteMatch(spec, ingressRoutes);
119
133
  const baseDomain = (0, nip_base_domain_util_1.buildNipBaseDomain)(masterIp, nipHostnameToken);
120
134
  const defaultFqdn = spec.defaultSubdomain
121
135
  ? `${spec.defaultSubdomain}.${baseDomain}`
@@ -35,6 +35,11 @@ export interface K3sMasterConfig {
35
35
  clusterFirewallId?: string;
36
36
  nipIoCertEnabled?: boolean;
37
37
  acmeStaging?: boolean;
38
+ /**
39
+ * Install from mobile tags instead of the pinned release: bootstrap ref
40
+ * `master` + `:latest` Docker images. Set by `flui env create --latest`.
41
+ */
42
+ useLatest?: boolean;
38
43
  nipHostnameToken?: string | null;
39
44
  envVnet?: {
40
45
  vnetProviderResourceId: string;
@@ -70,6 +75,8 @@ export interface K3sWorkerConfig {
70
75
  provider: string;
71
76
  caPublicKey?: string;
72
77
  operationId?: string;
78
+ /** See K3sMasterConfig.useLatest — keeps the worker on the same bootstrap ref. */
79
+ useLatest?: boolean;
73
80
  /**
74
81
  * Flui shared storage on workers: install cachefilesd + mount NFS export
75
82
  * from master. See scaling doc §14.
@@ -82,7 +89,7 @@ export interface K3sWorkerConfig {
82
89
  /**
83
90
  * CLI K3s Script Service
84
91
  *
85
- * Generates K3s initialization scripts for observability clusters.
92
+ * Generates K3s initialization scripts for control clusters.
86
93
  * Uses scripts from cli/src/modules/instances/assets/scripts/ directory.
87
94
  */
88
95
  export declare class CliK3sScriptService {