@fenwave/agent 1.1.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.
- package/.claude/settings.local.json +11 -0
- package/Dockerfile +12 -0
- package/LICENSE +29 -0
- package/README.md +434 -0
- package/auth.js +276 -0
- package/cli-commands.js +1185 -0
- package/containerManager.js +385 -0
- package/convert-to-esm.sh +62 -0
- package/docker-actions/apps.js +3256 -0
- package/docker-actions/config-transformer.js +380 -0
- package/docker-actions/containers.js +346 -0
- package/docker-actions/general.js +171 -0
- package/docker-actions/images.js +1128 -0
- package/docker-actions/logs.js +188 -0
- package/docker-actions/metrics.js +270 -0
- package/docker-actions/registry.js +1100 -0
- package/docker-actions/terminal.js +247 -0
- package/docker-actions/volumes.js +696 -0
- package/helper-functions.js +193 -0
- package/index.html +60 -0
- package/index.js +988 -0
- package/package.json +49 -0
- package/setup/setupWizard.js +499 -0
- package/store/agentSessionStore.js +51 -0
- package/store/agentStore.js +113 -0
- package/store/configStore.js +174 -0
- package/store/deviceCredentialStore.js +107 -0
- package/store/npmTokenStore.js +65 -0
- package/store/registryStore.js +329 -0
- package/store/setupState.js +147 -0
- package/utils/deviceInfo.js +98 -0
- package/utils/ecrAuth.js +225 -0
- package/utils/encryption.js +112 -0
- package/utils/envSetup.js +54 -0
- package/utils/errorHandler.js +327 -0
- package/utils/prerequisites.js +323 -0
- package/utils/prompts.js +318 -0
- package/websocket-server.js +364 -0
|
@@ -0,0 +1,3256 @@
|
|
|
1
|
+
import { docker } from "./containers.js";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path, { dirname } from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import { exec } from "child_process";
|
|
7
|
+
import axios from "axios";
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { loadSession } from "../auth.js";
|
|
10
|
+
import { loadConfig } from "../store/configStore.js";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import {
|
|
13
|
+
toDockerComposeConfig,
|
|
14
|
+
generateHostEnvScript,
|
|
15
|
+
generateEnvFile,
|
|
16
|
+
generateComposeDeploy,
|
|
17
|
+
} from "./config-transformer.js";
|
|
18
|
+
|
|
19
|
+
// ES Module equivalent of __dirname
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
|
|
23
|
+
// Load configuration
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
const APP_BUILDER_URL = config.appBuilderUrl;
|
|
26
|
+
const BACKEND_URL = config.backendUrl;
|
|
27
|
+
|
|
28
|
+
// Fenwave workspace configuration paths
|
|
29
|
+
const FENWAVE_CONFIG_DIR = path.join(os.homedir(), ".fenwave", "config");
|
|
30
|
+
const WORKSPACE_REGISTRY_PATH = path.join(
|
|
31
|
+
FENWAVE_CONFIG_DIR,
|
|
32
|
+
"workspaces.json"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Workspace metadata management
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// Ensure Fenwave config directory exists
|
|
40
|
+
function ensureFenwaveConfigDir() {
|
|
41
|
+
if (!fs.existsSync(FENWAVE_CONFIG_DIR)) {
|
|
42
|
+
fs.mkdirSync(FENWAVE_CONFIG_DIR, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Load workspace registry (maps appId -> workspace path)
|
|
47
|
+
function loadWorkspaceRegistry() {
|
|
48
|
+
ensureFenwaveConfigDir();
|
|
49
|
+
if (!fs.existsSync(WORKSPACE_REGISTRY_PATH)) {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const data = fs.readFileSync(WORKSPACE_REGISTRY_PATH, "utf8");
|
|
54
|
+
return JSON.parse(data);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.warn("⚠️ Failed to load workspace registry:", error.message);
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Save workspace registry
|
|
62
|
+
function saveWorkspaceRegistry(registry) {
|
|
63
|
+
ensureFenwaveConfigDir();
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(
|
|
66
|
+
WORKSPACE_REGISTRY_PATH,
|
|
67
|
+
JSON.stringify(registry, null, 2),
|
|
68
|
+
"utf8"
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error("❌ Failed to save workspace registry:", error.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Register a workspace for an app version
|
|
76
|
+
function registerWorkspace(appId, appName, appVersion, workspacePath) {
|
|
77
|
+
const registry = loadWorkspaceRegistry();
|
|
78
|
+
const registryKey = `${appId}-${appVersion}`;
|
|
79
|
+
registry[registryKey] = {
|
|
80
|
+
appId,
|
|
81
|
+
appName,
|
|
82
|
+
appVersion,
|
|
83
|
+
workspacePath,
|
|
84
|
+
registeredAt: new Date().toISOString(),
|
|
85
|
+
};
|
|
86
|
+
saveWorkspaceRegistry(registry);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unregister a workspace for an app version
|
|
90
|
+
function unregisterWorkspace(appId, appVersion) {
|
|
91
|
+
const registry = loadWorkspaceRegistry();
|
|
92
|
+
const registryKey = `${appId}-${appVersion}`;
|
|
93
|
+
if (registry[registryKey]) {
|
|
94
|
+
delete registry[registryKey];
|
|
95
|
+
saveWorkspaceRegistry(registry);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Get registered workspace path for an app version
|
|
102
|
+
function getRegisteredWorkspace(appId, appVersion) {
|
|
103
|
+
const registry = loadWorkspaceRegistry();
|
|
104
|
+
const registryKey = `${appId}-${appVersion}`;
|
|
105
|
+
return registry[registryKey] || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Delete workspace folder recursively
|
|
109
|
+
function deleteWorkspaceFolder(workspacePath) {
|
|
110
|
+
if (workspacePath && fs.existsSync(workspacePath)) {
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(workspacePath, { recursive: true, force: true });
|
|
113
|
+
console.log(`🗑️ Deleted workspace folder: ${workspacePath}`);
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.warn(`⚠️ Failed to delete workspace folder: ${error.message}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create workspace metadata file
|
|
124
|
+
function createWorkspaceMetadata(workspacePath, appId, appName) {
|
|
125
|
+
const fenwaveDir = path.join(workspacePath, ".fenwave");
|
|
126
|
+
if (!fs.existsSync(fenwaveDir)) {
|
|
127
|
+
fs.mkdirSync(fenwaveDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const metadata = {
|
|
131
|
+
appId,
|
|
132
|
+
appName,
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
lastSyncedAt: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const metadataPath = path.join(fenwaveDir, "workspace.json");
|
|
138
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Update workspace metadata (e.g., after sync)
|
|
142
|
+
function updateWorkspaceMetadata(workspacePath) {
|
|
143
|
+
const metadataPath = path.join(workspacePath, ".fenwave", "workspace.json");
|
|
144
|
+
|
|
145
|
+
if (!fs.existsSync(metadataPath)) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8"));
|
|
151
|
+
metadata.lastSyncedAt = new Date().toISOString();
|
|
152
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), "utf8");
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.warn("⚠️ Failed to update workspace metadata:", error.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Verify workspace is valid (has .fenwave/workspace.json)
|
|
159
|
+
function isValidWorkspace(workspacePath) {
|
|
160
|
+
const metadataPath = path.join(workspacePath, ".fenwave", "workspace.json");
|
|
161
|
+
return fs.existsSync(metadataPath);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve workspace path for each app version
|
|
165
|
+
function resolveWorkspacePath(appId, appName, appVersion, context = "default") {
|
|
166
|
+
// Check if workspace is registered
|
|
167
|
+
const registered = getRegisteredWorkspace(appId, appVersion);
|
|
168
|
+
|
|
169
|
+
if (registered) {
|
|
170
|
+
// Verify the registered path is still valid
|
|
171
|
+
if (isValidWorkspace(registered.workspacePath)) {
|
|
172
|
+
return registered.workspacePath;
|
|
173
|
+
} else {
|
|
174
|
+
if (context === "delete") {
|
|
175
|
+
console.warn(
|
|
176
|
+
`⚠️ Registered workspace not found at ${registered.workspacePath}, skipping workspace deletion...`
|
|
177
|
+
);
|
|
178
|
+
} else if (context !== "sync") {
|
|
179
|
+
console.warn(
|
|
180
|
+
`⚠️ Registered workspace not found at ${registered.workspacePath}, will recreate...`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create new workspace in ~/.fenwave/docker/
|
|
187
|
+
const fenwaveDockerDir = path.join(os.homedir(), ".fenwave", "docker");
|
|
188
|
+
const normalizedAppName = appName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
189
|
+
const newWorkspacePath = path.join(
|
|
190
|
+
fenwaveDockerDir,
|
|
191
|
+
`${normalizedAppName}-${appVersion}`
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
return newWorkspacePath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Resolve workspace path for draft apps (no version suffix)
|
|
198
|
+
function resolveWorkspacePathForDraft(appId, appName, context = "default") {
|
|
199
|
+
// Check if workspace is registered for draft (using "draft" as version key)
|
|
200
|
+
const registered = getRegisteredWorkspace(appId, "draft");
|
|
201
|
+
|
|
202
|
+
if (registered) {
|
|
203
|
+
// Verify the registered path is still valid
|
|
204
|
+
if (isValidWorkspace(registered.workspacePath)) {
|
|
205
|
+
return registered.workspacePath;
|
|
206
|
+
} else {
|
|
207
|
+
if (context === "delete") {
|
|
208
|
+
console.warn(
|
|
209
|
+
`⚠️ Registered workspace not found at ${registered.workspacePath}, skipping workspace deletion...`
|
|
210
|
+
);
|
|
211
|
+
} else if (context !== "sync") {
|
|
212
|
+
console.warn(
|
|
213
|
+
`⚠️ Registered workspace not found at ${registered.workspacePath}, will recreate...`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Create new workspace in ~/.fenwave/docker/ without version suffix
|
|
220
|
+
const fenwaveDockerDir = path.join(os.homedir(), ".fenwave", "docker");
|
|
221
|
+
const normalizedAppName = appName.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
222
|
+
const newWorkspacePath = path.join(fenwaveDockerDir, normalizedAppName);
|
|
223
|
+
|
|
224
|
+
return newWorkspacePath;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Backup existing docker-compose.yml before sync
|
|
228
|
+
function backupDockerCompose(workspacePath) {
|
|
229
|
+
const dockerComposePath = path.join(workspacePath, "docker-compose.yml");
|
|
230
|
+
const backupPath = path.join(workspacePath, "docker-compose.backup.yml");
|
|
231
|
+
|
|
232
|
+
if (fs.existsSync(dockerComposePath)) {
|
|
233
|
+
try {
|
|
234
|
+
fs.copyFileSync(dockerComposePath, backupPath);
|
|
235
|
+
console.log(`📦 Backed up existing docker-compose.yml`);
|
|
236
|
+
return true;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn("⚠️ Failed to backup docker-compose.yml:", error.message);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if an app has ever been run locally (has containers created)
|
|
247
|
+
* @param {string} appName - The app name to check
|
|
248
|
+
* @returns {Promise<boolean>} - True if the app has been run at least once
|
|
249
|
+
*/
|
|
250
|
+
async function checkAppHasBeenRun(appName) {
|
|
251
|
+
if (!appName) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
// Normalize app name to match Docker container naming convention
|
|
257
|
+
const normalizedAppName = appName
|
|
258
|
+
.replace(/[^a-z0-9-]/gi, "-")
|
|
259
|
+
.toLowerCase();
|
|
260
|
+
|
|
261
|
+
const containers = await docker.listContainers({ all: true });
|
|
262
|
+
const appContainers = containers.filter((container) => {
|
|
263
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
264
|
+
return (
|
|
265
|
+
containerName.startsWith(normalizedAppName) ||
|
|
266
|
+
container.Labels["com.docker.compose.project"] === normalizedAppName ||
|
|
267
|
+
container.Labels["app"] === normalizedAppName
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return appContainers.length > 0;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// If we can't check containers, assume the app has been run
|
|
274
|
+
// to avoid missing important update notifications
|
|
275
|
+
console.warn(
|
|
276
|
+
`⚠️ Failed to check containers for app ${appName}: ${error.message}`
|
|
277
|
+
);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Acknowledge an event after processing (e.g., after syncing)
|
|
284
|
+
*/
|
|
285
|
+
async function acknowledgeEvent(eventId, changeId) {
|
|
286
|
+
try {
|
|
287
|
+
const session = loadSession();
|
|
288
|
+
if (!session || !session.token) {
|
|
289
|
+
console.warn("⚠️ No session token available to acknowledge event");
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const response = await axios.post(
|
|
294
|
+
`${BACKEND_URL}/api/agent-cli/acknowledge-event`,
|
|
295
|
+
{
|
|
296
|
+
token: session.token,
|
|
297
|
+
eventId,
|
|
298
|
+
changeId,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
headers: {
|
|
302
|
+
"Content-Type": "application/json",
|
|
303
|
+
},
|
|
304
|
+
timeout: 5000,
|
|
305
|
+
}
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (response.data && response.data.success) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return false;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.warn(`⚠️ Failed to acknowledge event: ${error.message}`);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function handleAppAction(ws, action, payload) {
|
|
320
|
+
switch (action) {
|
|
321
|
+
case "fetchApps":
|
|
322
|
+
return await handleFetchApps(ws, payload);
|
|
323
|
+
case "fetchAppVersions":
|
|
324
|
+
return await handleFetchAppVersions(ws, payload);
|
|
325
|
+
case "startApp":
|
|
326
|
+
return await handleStartApp(ws, payload);
|
|
327
|
+
case "stopApp":
|
|
328
|
+
return await handleStopApp(ws, payload);
|
|
329
|
+
case "restartApp":
|
|
330
|
+
return await handleRestartApp(ws, payload);
|
|
331
|
+
case "deleteApp":
|
|
332
|
+
return await handleDeleteApp(ws, payload);
|
|
333
|
+
case "deleteAppVersions":
|
|
334
|
+
return await handleDeleteAppVersions(ws, payload);
|
|
335
|
+
case "createApp":
|
|
336
|
+
return await handleCreateApp(ws, payload);
|
|
337
|
+
case "validateCompose":
|
|
338
|
+
return await handleValidateCompose(ws, payload);
|
|
339
|
+
case "syncApp":
|
|
340
|
+
return await handleSyncApp(ws, payload);
|
|
341
|
+
case "changeVersion":
|
|
342
|
+
return await handleChangeVersion(ws, payload);
|
|
343
|
+
default:
|
|
344
|
+
throw new Error(`Unknown app action: ${action}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Fetch published applications from Backstage app-builder
|
|
349
|
+
async function fetchBackstageApps(userEntityRef) {
|
|
350
|
+
if (!APP_BUILDER_URL) {
|
|
351
|
+
console.warn("APP_BUILDER_URL not configured, skipping Backstage apps");
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
// Load session token for authentication
|
|
357
|
+
const session = loadSession();
|
|
358
|
+
if (!session || !session.token) {
|
|
359
|
+
console.warn(
|
|
360
|
+
"No valid session found, skipping Backstage apps. Please run: fenwave login"
|
|
361
|
+
);
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const url = `${APP_BUILDER_URL}/api/app-builder/applications`;
|
|
366
|
+
|
|
367
|
+
// Fetch both published apps and user's drafts
|
|
368
|
+
const [publishedResponse, draftResponse] = await Promise.all([
|
|
369
|
+
// Fetch all published apps
|
|
370
|
+
axios
|
|
371
|
+
.get(url, {
|
|
372
|
+
params: { status: "published" },
|
|
373
|
+
headers: {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
Authorization: `Bearer ${session.token}`,
|
|
376
|
+
},
|
|
377
|
+
timeout: 10000,
|
|
378
|
+
})
|
|
379
|
+
.catch((err) => {
|
|
380
|
+
const isConnRefused =
|
|
381
|
+
(err.message && err.message.includes("ECONNREFUSED")) ||
|
|
382
|
+
err.code === "ECONNREFUSED";
|
|
383
|
+
|
|
384
|
+
if (isConnRefused) {
|
|
385
|
+
console.error(
|
|
386
|
+
chalk.red(
|
|
387
|
+
"❌ Failed to fetch published apps: Please ensure backstage is running."
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
} else {
|
|
391
|
+
console.error(
|
|
392
|
+
chalk.red("❌ Failed to fetch published apps:", err.message)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { data: [] };
|
|
397
|
+
}),
|
|
398
|
+
// Fetch user's draft apps
|
|
399
|
+
axios
|
|
400
|
+
.get(url, {
|
|
401
|
+
params: {
|
|
402
|
+
status: "draft",
|
|
403
|
+
created_by: userEntityRef,
|
|
404
|
+
},
|
|
405
|
+
headers: {
|
|
406
|
+
"Content-Type": "application/json",
|
|
407
|
+
Authorization: `Bearer ${session.token}`,
|
|
408
|
+
},
|
|
409
|
+
timeout: 10000,
|
|
410
|
+
})
|
|
411
|
+
.catch((err) => {
|
|
412
|
+
const isConnRefused =
|
|
413
|
+
(err.message && err.message.includes("ECONNREFUSED")) ||
|
|
414
|
+
err.code === "ECONNREFUSED";
|
|
415
|
+
|
|
416
|
+
if (isConnRefused) {
|
|
417
|
+
console.error(
|
|
418
|
+
chalk.red(
|
|
419
|
+
"❌ Failed to fetch draft apps: Please ensure backstage is running."
|
|
420
|
+
)
|
|
421
|
+
);
|
|
422
|
+
} else {
|
|
423
|
+
console.error(
|
|
424
|
+
chalk.red("❌ Failed to fetch draft apps:", err.message)
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { data: [] };
|
|
429
|
+
}),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
const publishedApps = publishedResponse.data || [];
|
|
433
|
+
const draftApps = draftResponse.data || [];
|
|
434
|
+
const allApps = [...publishedApps, ...draftApps];
|
|
435
|
+
|
|
436
|
+
// Transform Backstage apps to local app format (now async to check container status)
|
|
437
|
+
return await Promise.all(allApps.map((app) => transformBackstageApp(app)));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error("Failed to fetch Backstage apps:", error.message);
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Transform Backstage app to local app format
|
|
445
|
+
async function transformBackstageApp(backstageApp) {
|
|
446
|
+
// Fetch all versions for this app
|
|
447
|
+
const session = loadSession();
|
|
448
|
+
let allVersions = [];
|
|
449
|
+
|
|
450
|
+
if (session && session.token && APP_BUILDER_URL) {
|
|
451
|
+
try {
|
|
452
|
+
const versionsResponse = await axios.get(
|
|
453
|
+
`${APP_BUILDER_URL}/api/app-builder/applications/${backstageApp.id}/versions`,
|
|
454
|
+
{
|
|
455
|
+
headers: {
|
|
456
|
+
"Content-Type": "application/json",
|
|
457
|
+
Authorization: `Bearer ${session.token}`,
|
|
458
|
+
},
|
|
459
|
+
timeout: 10000,
|
|
460
|
+
}
|
|
461
|
+
);
|
|
462
|
+
allVersions = versionsResponse.data || [];
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.error(
|
|
465
|
+
`Failed to fetch versions for app ${backstageApp.id}:`,
|
|
466
|
+
err.message
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// If no versions found, use activeVersion from app
|
|
472
|
+
if (allVersions.length === 0 && backstageApp.activeVersion) {
|
|
473
|
+
allVersions = [backstageApp.activeVersion];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Check if there are running Docker containers for this app
|
|
477
|
+
const appName = backstageApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
478
|
+
|
|
479
|
+
// Get container info to determine which version is running (or: has ran) locally
|
|
480
|
+
let containers = [];
|
|
481
|
+
let runningVersion = null; // Track which version is actually running
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
containers = await docker.listContainers({ all: true });
|
|
485
|
+
containers = containers.filter((container) => {
|
|
486
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
487
|
+
return (
|
|
488
|
+
containerName.startsWith(appName) ||
|
|
489
|
+
container.Labels["com.docker.compose.project"] === appName ||
|
|
490
|
+
container.Labels["app"] === appName
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Try to determine the running version (or: that has ran) from container labels
|
|
495
|
+
if (containers.length > 0) {
|
|
496
|
+
// Check the first container for version label
|
|
497
|
+
const versionLabel = containers[0].Labels["app.version"];
|
|
498
|
+
if (versionLabel) {
|
|
499
|
+
runningVersion = versionLabel;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error(
|
|
504
|
+
`Error checking containers for app ${appName}:`,
|
|
505
|
+
error.message
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const containerCount = containers.length;
|
|
510
|
+
const hasRunningContainer = containers.some((c) => c.State === "running");
|
|
511
|
+
const appStatus = hasRunningContainer
|
|
512
|
+
? "running"
|
|
513
|
+
: containerCount > 0
|
|
514
|
+
? "stopped"
|
|
515
|
+
: "stopped";
|
|
516
|
+
|
|
517
|
+
// Calculate firstRun and lastRun times for containers
|
|
518
|
+
let firstRunTime = "Never";
|
|
519
|
+
let lastRunTime = "Never";
|
|
520
|
+
|
|
521
|
+
if (containers.length > 0) {
|
|
522
|
+
const creationTimestamps = containers.map((c) =>
|
|
523
|
+
new Date(c.Created * 1000).getTime()
|
|
524
|
+
);
|
|
525
|
+
const oldestCreationTimestamp = Math.min(...creationTimestamps);
|
|
526
|
+
|
|
527
|
+
const startTimestamps = [];
|
|
528
|
+
for (const container of containers) {
|
|
529
|
+
try {
|
|
530
|
+
const containerInfo = await docker.getContainer(container.Id).inspect();
|
|
531
|
+
if (containerInfo.State.StartedAt) {
|
|
532
|
+
startTimestamps.push(
|
|
533
|
+
new Date(containerInfo.State.StartedAt).getTime()
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
startTimestamps.push(new Date(container.Created * 1000).getTime());
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const mostRecentStartTimestamp = Math.max(...startTimestamps);
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
|
|
544
|
+
// Calculate firstRun
|
|
545
|
+
const firstDiff = now - oldestCreationTimestamp;
|
|
546
|
+
const firstMinutes = Math.floor(firstDiff / (1000 * 60));
|
|
547
|
+
const firstHours = Math.floor(firstMinutes / 60);
|
|
548
|
+
const firstDays = Math.floor(firstHours / 24);
|
|
549
|
+
|
|
550
|
+
if (firstDays > 0)
|
|
551
|
+
firstRunTime = `${firstDays} day${firstDays > 1 ? "s" : ""} ago`;
|
|
552
|
+
else if (firstHours > 0)
|
|
553
|
+
firstRunTime = `${firstHours} hour${firstHours > 1 ? "s" : ""} ago`;
|
|
554
|
+
else if (firstMinutes > 0)
|
|
555
|
+
firstRunTime = `${firstMinutes} minute${firstMinutes > 1 ? "s" : ""} ago`;
|
|
556
|
+
else firstRunTime = "Just now";
|
|
557
|
+
|
|
558
|
+
// Calculate lastRun
|
|
559
|
+
const lastDiff = now - mostRecentStartTimestamp;
|
|
560
|
+
const lastMinutes = Math.floor(lastDiff / (1000 * 60));
|
|
561
|
+
const lastHours = Math.floor(lastMinutes / 60);
|
|
562
|
+
const lastDays = Math.floor(lastHours / 24);
|
|
563
|
+
|
|
564
|
+
if (lastDays > 0)
|
|
565
|
+
lastRunTime = `${lastDays} day${lastDays > 1 ? "s" : ""} ago`;
|
|
566
|
+
else if (lastHours > 0)
|
|
567
|
+
lastRunTime = `${lastHours} hour${lastHours > 1 ? "s" : ""} ago`;
|
|
568
|
+
else if (lastMinutes > 0)
|
|
569
|
+
lastRunTime = `${lastMinutes} minute${lastMinutes > 1 ? "s" : ""} ago`;
|
|
570
|
+
else lastRunTime = "Just now";
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Transform all versions to include local run status
|
|
574
|
+
const transformedVersions = allVersions.map((version) => {
|
|
575
|
+
const nodes = version.nodes || [];
|
|
576
|
+
const componentTypes = nodes.map((n) => n?.data?.type).filter(Boolean);
|
|
577
|
+
const language = componentTypes[0] || "Unknown";
|
|
578
|
+
|
|
579
|
+
// Check if THIS specific version is the one running (or: that has ran) locally
|
|
580
|
+
const isThisVersionRunning =
|
|
581
|
+
runningVersion && version.version === runningVersion;
|
|
582
|
+
const versionHasRun = isThisVersionRunning && containerCount > 0;
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
id: version.id,
|
|
586
|
+
version: version.version || "1.0.0",
|
|
587
|
+
status: version.status, // draft, published, or archived
|
|
588
|
+
description:
|
|
589
|
+
version.description ||
|
|
590
|
+
backstageApp.description ||
|
|
591
|
+
"Backstage application",
|
|
592
|
+
nodes: nodes,
|
|
593
|
+
edges: version.edges || [],
|
|
594
|
+
tags: version.tags || [],
|
|
595
|
+
language: language,
|
|
596
|
+
created_at: version.created_at,
|
|
597
|
+
created_by: version.created_by,
|
|
598
|
+
// Mark if THIS SPECIFIC version has ran locally
|
|
599
|
+
hasRanLocally: versionHasRun,
|
|
600
|
+
containers: versionHasRun ? containerCount : 0,
|
|
601
|
+
firstRun: versionHasRun ? firstRunTime : "Never",
|
|
602
|
+
lastRun: versionHasRun ? lastRunTime : "Never",
|
|
603
|
+
appStatus: versionHasRun ? appStatus : "stopped",
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Get the active published version
|
|
608
|
+
const activeVersion =
|
|
609
|
+
allVersions.find((v) => v.status === "published") || allVersions[0] || {};
|
|
610
|
+
const nodes = activeVersion.nodes || [];
|
|
611
|
+
const componentTypes = nodes.map((n) => n?.data?.type).filter(Boolean);
|
|
612
|
+
const language = componentTypes[0] || "Unknown";
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
id: `backstage-${backstageApp.id}`,
|
|
616
|
+
name: backstageApp.name,
|
|
617
|
+
description:
|
|
618
|
+
backstageApp.description ||
|
|
619
|
+
activeVersion.description ||
|
|
620
|
+
"Backstage application",
|
|
621
|
+
language: language,
|
|
622
|
+
status: appStatus,
|
|
623
|
+
containers: containerCount,
|
|
624
|
+
firstRun: firstRunTime,
|
|
625
|
+
lastRun: lastRunTime,
|
|
626
|
+
url: "N/A", // Will be set after running
|
|
627
|
+
repo: backstageApp.metadata?.repo || "N/A",
|
|
628
|
+
icon: backstageApp.name.substring(0, 1).toUpperCase(),
|
|
629
|
+
source: "backstage",
|
|
630
|
+
backstageId: backstageApp.id,
|
|
631
|
+
backstageStatus: activeVersion.status || backstageApp.status,
|
|
632
|
+
version: activeVersion.version || "1.0.0",
|
|
633
|
+
tags: activeVersion.tags || [],
|
|
634
|
+
nodes: nodes,
|
|
635
|
+
edges: activeVersion.edges || [],
|
|
636
|
+
createdBy: backstageApp.created_by,
|
|
637
|
+
publishedAt: backstageApp.published_at,
|
|
638
|
+
publishedBy: backstageApp.published_by,
|
|
639
|
+
versions: transformedVersions,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Helper function to find apps (Backstage only)
|
|
644
|
+
async function findApps(userEntityRef) {
|
|
645
|
+
try {
|
|
646
|
+
// Only fetch Backstage apps (no local Docker containers)
|
|
647
|
+
const backstageApps = await fetchBackstageApps(userEntityRef);
|
|
648
|
+
|
|
649
|
+
return backstageApps;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.error("Error finding apps:", error);
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Find Docker Compose apps
|
|
657
|
+
async function findComposeApps() {
|
|
658
|
+
try {
|
|
659
|
+
// This is a placeholder. In a real implementation, you would scan for
|
|
660
|
+
// docker-compose.yml files in the user's projects directory
|
|
661
|
+
return [];
|
|
662
|
+
} catch (error) {
|
|
663
|
+
console.error("Error finding compose apps:", error);
|
|
664
|
+
return [];
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Group containers into apps based on labels or networks
|
|
669
|
+
async function groupContainersIntoApps() {
|
|
670
|
+
try {
|
|
671
|
+
const containers = await docker.listContainers({ all: true });
|
|
672
|
+
const apps = {};
|
|
673
|
+
|
|
674
|
+
const detectLanguage = (image) => {
|
|
675
|
+
const imageLower = image.toLowerCase();
|
|
676
|
+
|
|
677
|
+
if (imageLower.includes("node") || imageLower.includes("javascript"))
|
|
678
|
+
return "JavaScript";
|
|
679
|
+
if (imageLower.includes("python")) return "Python";
|
|
680
|
+
if (imageLower.includes("ruby")) return "Ruby";
|
|
681
|
+
if (imageLower.includes("php")) return "PHP";
|
|
682
|
+
if (imageLower.includes("java")) return "Java";
|
|
683
|
+
if (imageLower.includes("go")) return "Go";
|
|
684
|
+
if (imageLower.includes("rust")) return "Rust";
|
|
685
|
+
if (imageLower.includes("dotnet") || imageLower.includes("csharp"))
|
|
686
|
+
return "C#";
|
|
687
|
+
|
|
688
|
+
return "Unknown";
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const formatRelativeTime = (timestamp) => {
|
|
692
|
+
const now = Date.now();
|
|
693
|
+
const diff = now - timestamp;
|
|
694
|
+
|
|
695
|
+
const minutes = Math.floor(diff / (1000 * 60));
|
|
696
|
+
const hours = Math.floor(minutes / 60);
|
|
697
|
+
const days = Math.floor(hours / 24);
|
|
698
|
+
const weeks = Math.floor(days / 7);
|
|
699
|
+
|
|
700
|
+
if (weeks > 0) return `${weeks} week${weeks > 1 ? "s" : ""} ago`;
|
|
701
|
+
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
|
702
|
+
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
|
703
|
+
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
|
704
|
+
return "Just now";
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Group by common labels or networks
|
|
708
|
+
for (const container of containers) {
|
|
709
|
+
const appName =
|
|
710
|
+
container.Labels["com.docker.compose.project"] ||
|
|
711
|
+
container.Labels["app"] ||
|
|
712
|
+
container.Names[0].replace(/^\//, "").split("_")[0];
|
|
713
|
+
|
|
714
|
+
if (!appName) continue;
|
|
715
|
+
|
|
716
|
+
// Create or update app
|
|
717
|
+
if (!apps[appName]) {
|
|
718
|
+
const containerInfo = await docker.getContainer(container.Id).inspect();
|
|
719
|
+
const image = containerInfo.Config.Image;
|
|
720
|
+
const createdTime = new Date(containerInfo.Created).getTime();
|
|
721
|
+
|
|
722
|
+
// Use StartedAt for lastRun if available, otherwise fall back to Created
|
|
723
|
+
const startedAt = containerInfo.State.StartedAt
|
|
724
|
+
? new Date(containerInfo.State.StartedAt).getTime()
|
|
725
|
+
: createdTime;
|
|
726
|
+
|
|
727
|
+
apps[appName] = {
|
|
728
|
+
id: `app-${appName}`,
|
|
729
|
+
name: appName,
|
|
730
|
+
description: `Application using ${image}`,
|
|
731
|
+
language: detectLanguage(image),
|
|
732
|
+
status: containerInfo.State.Running ? "running" : "stopped",
|
|
733
|
+
containers: 1,
|
|
734
|
+
firstRun: formatRelativeTime(createdTime),
|
|
735
|
+
lastRun: formatRelativeTime(startedAt),
|
|
736
|
+
oldestCreationTime: createdTime, // Track oldest creation for firstRun
|
|
737
|
+
newestStartTime: startedAt, // Track most recent start for lastRun
|
|
738
|
+
url: `http://localhost:${
|
|
739
|
+
containerInfo.NetworkSettings.Ports
|
|
740
|
+
? Object.keys(containerInfo.NetworkSettings.Ports)[0]?.split(
|
|
741
|
+
"/"
|
|
742
|
+
)[0]
|
|
743
|
+
: "8080"
|
|
744
|
+
}`,
|
|
745
|
+
repo: container.Labels["repo"] || "local",
|
|
746
|
+
icon: appName.substring(0, 1).toUpperCase(),
|
|
747
|
+
};
|
|
748
|
+
} else {
|
|
749
|
+
// Update app with additional container
|
|
750
|
+
apps[appName].containers += 1;
|
|
751
|
+
|
|
752
|
+
// Update status if any container is running
|
|
753
|
+
const containerInfo = await docker.getContainer(container.Id).inspect();
|
|
754
|
+
if (containerInfo.State.Running) {
|
|
755
|
+
apps[appName].status = "running";
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Track oldest creation time and newest start time
|
|
759
|
+
const createdTime = new Date(containerInfo.Created).getTime();
|
|
760
|
+
const startedAt = containerInfo.State.StartedAt
|
|
761
|
+
? new Date(containerInfo.State.StartedAt).getTime()
|
|
762
|
+
: createdTime;
|
|
763
|
+
|
|
764
|
+
// Update firstRun if this container was created earlier
|
|
765
|
+
if (createdTime < apps[appName].oldestCreationTime) {
|
|
766
|
+
apps[appName].oldestCreationTime = createdTime;
|
|
767
|
+
apps[appName].firstRun = formatRelativeTime(createdTime);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Update lastRun if this container was started more recently
|
|
771
|
+
if (startedAt > apps[appName].newestStartTime) {
|
|
772
|
+
apps[appName].newestStartTime = startedAt;
|
|
773
|
+
apps[appName].lastRun = formatRelativeTime(startedAt);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Clean up temporary tracking fields before returning
|
|
779
|
+
const cleanedApps = Object.values(apps).map((app) => {
|
|
780
|
+
const { oldestCreationTime, newestStartTime, ...cleanApp } = app;
|
|
781
|
+
return cleanApp;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
return cleanedApps;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
console.error("Error grouping containers into apps:", error);
|
|
787
|
+
return [];
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async function handleFetchApps(ws, payload = {}) {
|
|
792
|
+
try {
|
|
793
|
+
// Extract user context from payload (passed from frontend)
|
|
794
|
+
const userEntityRef = payload.userEntityRef || null;
|
|
795
|
+
|
|
796
|
+
// Fetch apps (local + Backstage)
|
|
797
|
+
const apps = await findApps(userEntityRef);
|
|
798
|
+
|
|
799
|
+
ws.send(
|
|
800
|
+
JSON.stringify({
|
|
801
|
+
type: "apps",
|
|
802
|
+
apps,
|
|
803
|
+
requestId: payload.requestId,
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
console.error("Error fetching apps:", error);
|
|
808
|
+
ws.send(
|
|
809
|
+
JSON.stringify({
|
|
810
|
+
type: "error",
|
|
811
|
+
error: "Failed to fetch apps: " + error.message,
|
|
812
|
+
requestId: payload.requestId,
|
|
813
|
+
})
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function handleFetchAppVersions(ws, payload) {
|
|
819
|
+
try {
|
|
820
|
+
const { backstageId, requestId } = payload;
|
|
821
|
+
|
|
822
|
+
if (!backstageId) {
|
|
823
|
+
throw new Error("backstageId is required");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (!APP_BUILDER_URL) {
|
|
827
|
+
throw new Error("APP_BUILDER_URL not configured");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Load session token for authentication
|
|
831
|
+
const session = loadSession();
|
|
832
|
+
if (!session || !session.token) {
|
|
833
|
+
throw new Error("No valid session found. Please run: fenwave login");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Fetch app info to get the name for container matching
|
|
837
|
+
const appUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${backstageId}`;
|
|
838
|
+
const appResponse = await axios.get(appUrl, {
|
|
839
|
+
headers: {
|
|
840
|
+
"Content-Type": "application/json",
|
|
841
|
+
Authorization: `Bearer ${session.token}`,
|
|
842
|
+
},
|
|
843
|
+
timeout: 10000,
|
|
844
|
+
});
|
|
845
|
+
const appData = appResponse.data;
|
|
846
|
+
const appName = appData.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
847
|
+
|
|
848
|
+
// Check for existing containers to determine running version
|
|
849
|
+
let runningVersion = null;
|
|
850
|
+
let containers = [];
|
|
851
|
+
try {
|
|
852
|
+
containers = await docker.listContainers({ all: true });
|
|
853
|
+
containers = containers.filter((container) => {
|
|
854
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
855
|
+
return (
|
|
856
|
+
containerName.startsWith(appName) ||
|
|
857
|
+
container.Labels["com.docker.compose.project"] === appName ||
|
|
858
|
+
container.Labels["app"] === appName
|
|
859
|
+
);
|
|
860
|
+
});
|
|
861
|
+
if (containers.length > 0 && containers[0].Labels["app.version"]) {
|
|
862
|
+
runningVersion = containers[0].Labels["app.version"];
|
|
863
|
+
}
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.warn("Error checking containers:", err.message);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const url = `${APP_BUILDER_URL}/api/app-builder/applications/${backstageId}/versions`;
|
|
869
|
+
const response = await axios.get(url, {
|
|
870
|
+
headers: {
|
|
871
|
+
"Content-Type": "application/json",
|
|
872
|
+
Authorization: `Bearer ${session.token}`,
|
|
873
|
+
},
|
|
874
|
+
timeout: 10000,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
const rawVersions = response.data || [];
|
|
878
|
+
|
|
879
|
+
// Enrich versions with local run status
|
|
880
|
+
const versions = rawVersions.map((version) => {
|
|
881
|
+
const isThisVersionRunning =
|
|
882
|
+
runningVersion && version.version === runningVersion;
|
|
883
|
+
const versionHasRun = isThisVersionRunning && containers.length > 0;
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
...version,
|
|
887
|
+
hasRanLocally: versionHasRun,
|
|
888
|
+
containers: versionHasRun ? containers.length : 0,
|
|
889
|
+
firstRun: versionHasRun ? "Yes" : "Never",
|
|
890
|
+
lastRun: versionHasRun ? "Yes" : "Never",
|
|
891
|
+
appStatus: versionHasRun
|
|
892
|
+
? containers.some((c) => c.State === "running")
|
|
893
|
+
? "running"
|
|
894
|
+
: "stopped"
|
|
895
|
+
: "stopped",
|
|
896
|
+
};
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
ws.send(
|
|
900
|
+
JSON.stringify({
|
|
901
|
+
type: "appVersions",
|
|
902
|
+
versions,
|
|
903
|
+
appId: backstageId,
|
|
904
|
+
requestId,
|
|
905
|
+
})
|
|
906
|
+
);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
console.error("Error fetching app versions:", error);
|
|
909
|
+
ws.send(
|
|
910
|
+
JSON.stringify({
|
|
911
|
+
type: "error",
|
|
912
|
+
error: "Failed to fetch app versions: " + error.message,
|
|
913
|
+
requestId: payload.requestId,
|
|
914
|
+
})
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function handleStartApp(ws, payload) {
|
|
920
|
+
try {
|
|
921
|
+
const { id, yamlContent, requestId } = payload;
|
|
922
|
+
|
|
923
|
+
// Extract user context from payload
|
|
924
|
+
const userEntityRef = payload.userEntityRef || null;
|
|
925
|
+
|
|
926
|
+
// Case 1: Called from App Builder with yamlContent
|
|
927
|
+
if (yamlContent && !id) {
|
|
928
|
+
// Parse YAML and extract application name safely
|
|
929
|
+
const appDefinition = yaml.load(yamlContent);
|
|
930
|
+
const appNameRaw =
|
|
931
|
+
appDefinition?.application?.name ?? appDefinition?.name;
|
|
932
|
+
|
|
933
|
+
if (!appNameRaw || typeof appNameRaw !== "string") {
|
|
934
|
+
throw new Error("Application name not found or invalid in YAML");
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const appName = appNameRaw.trim();
|
|
938
|
+
const normalizedAppName = appName
|
|
939
|
+
.replace(/[^a-z0-9-]/gi, "-")
|
|
940
|
+
.toLowerCase();
|
|
941
|
+
|
|
942
|
+
// Find the app in Backstage
|
|
943
|
+
const apps = await findApps(userEntityRef);
|
|
944
|
+
const app = apps.find(
|
|
945
|
+
(a) =>
|
|
946
|
+
a.name === appName ||
|
|
947
|
+
a.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase() ===
|
|
948
|
+
normalizedAppName
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
if (app) {
|
|
952
|
+
// Check if containers exist for this app
|
|
953
|
+
const containers = await docker.listContainers({ all: true });
|
|
954
|
+
const appContainers = containers.filter((container) => {
|
|
955
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
956
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
957
|
+
|
|
958
|
+
return (
|
|
959
|
+
containerName.startsWith(normalizedAppName) ||
|
|
960
|
+
projectLabel === normalizedAppName ||
|
|
961
|
+
container.Labels["app"] === normalizedAppName
|
|
962
|
+
);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// If containers exist, handle start or report already running
|
|
966
|
+
if (appContainers.length > 0) {
|
|
967
|
+
const hasRunningContainer = appContainers.some(
|
|
968
|
+
(c) => c.State === "running"
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
if (hasRunningContainer) {
|
|
972
|
+
console.log(
|
|
973
|
+
`⚠️ App "${appName}" is already running. Use the DevApp to manage it.`
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
ws.send(
|
|
977
|
+
JSON.stringify({
|
|
978
|
+
type: "error",
|
|
979
|
+
error: "Application is already running",
|
|
980
|
+
message: `App "${appName}" is already running. Use the DevApp to manage it.`,
|
|
981
|
+
isWarning: true,
|
|
982
|
+
requestId,
|
|
983
|
+
})
|
|
984
|
+
);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Unified start log
|
|
989
|
+
const isDraft = app.backstageStatus === "draft";
|
|
990
|
+
|
|
991
|
+
if (isDraft) {
|
|
992
|
+
console.log(`🚀 Running draft "${app.name}"...`);
|
|
993
|
+
} else {
|
|
994
|
+
console.log(
|
|
995
|
+
`🚀 Running application "${app.name} (${app.version})"...`
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Start any stopped containers
|
|
1000
|
+
for (const container of appContainers) {
|
|
1001
|
+
if (container.State !== "running") {
|
|
1002
|
+
await docker.getContainer(container.Id).start();
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (isDraft) {
|
|
1007
|
+
console.log(`✅ Draft "${app.name}" ran successfully!`);
|
|
1008
|
+
} else {
|
|
1009
|
+
console.log(
|
|
1010
|
+
`✅ Application "${app.name} (${app.version})" ran successfully!`
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
ws.send(
|
|
1015
|
+
JSON.stringify({
|
|
1016
|
+
type: "runCompleted",
|
|
1017
|
+
app: {
|
|
1018
|
+
...app,
|
|
1019
|
+
status: "running",
|
|
1020
|
+
containers: appContainers.length,
|
|
1021
|
+
},
|
|
1022
|
+
requestId,
|
|
1023
|
+
})
|
|
1024
|
+
);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// No containers exist - run the app for the first time
|
|
1029
|
+
await runBackstageApp(ws, app, yamlContent, requestId);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// App not found in Backstage - run as a new YAML-based app
|
|
1034
|
+
const newApp = {
|
|
1035
|
+
id: `app-${Date.now()}`,
|
|
1036
|
+
name: appName,
|
|
1037
|
+
backstageId: null,
|
|
1038
|
+
source: "yaml",
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
await runBackstageApp(ws, newApp, yamlContent, requestId);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Case 2: Called from local app list with id
|
|
1046
|
+
if (!id) {
|
|
1047
|
+
throw new Error("Either id or yamlContent is required");
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Find the app
|
|
1051
|
+
const apps = await findApps(userEntityRef);
|
|
1052
|
+
const app = apps.find((a) => a.id === id);
|
|
1053
|
+
|
|
1054
|
+
if (!app) {
|
|
1055
|
+
throw new Error("App not found");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Normalize app name for container matching
|
|
1059
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1060
|
+
|
|
1061
|
+
// Find containers belonging to this app
|
|
1062
|
+
const containers = await docker.listContainers({ all: true });
|
|
1063
|
+
const appContainers = containers.filter((container) => {
|
|
1064
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
1065
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
1066
|
+
|
|
1067
|
+
return (
|
|
1068
|
+
containerName.startsWith(appName) ||
|
|
1069
|
+
projectLabel === appName ||
|
|
1070
|
+
container.Labels["app"] === appName
|
|
1071
|
+
);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// If containers exist, just start them (don't re-run)
|
|
1075
|
+
if (appContainers.length > 0) {
|
|
1076
|
+
// Get version from container labels (the actual running version)
|
|
1077
|
+
const runningVersion =
|
|
1078
|
+
appContainers[0].Labels["app.version"] || app.version || "1.0.0";
|
|
1079
|
+
const isDraft = app.backstageStatus === "draft";
|
|
1080
|
+
|
|
1081
|
+
// Log with appropriate format for draft vs published
|
|
1082
|
+
if (isDraft) {
|
|
1083
|
+
console.log(`🚀 Running draft "${app.name}"...`);
|
|
1084
|
+
} else {
|
|
1085
|
+
console.log(
|
|
1086
|
+
`🚀 Running application "${app.name} (${runningVersion})"...`
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
for (const container of appContainers) {
|
|
1091
|
+
if (container.State !== "running") {
|
|
1092
|
+
await docker.getContainer(container.Id).start();
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (isDraft) {
|
|
1097
|
+
console.log(`✅ Draft "${app.name}" ran successfully!`);
|
|
1098
|
+
} else {
|
|
1099
|
+
console.log(
|
|
1100
|
+
`✅ Application "${app.name} (${runningVersion})" ran successfully!`
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
ws.send(
|
|
1105
|
+
JSON.stringify({
|
|
1106
|
+
type: "appStarted",
|
|
1107
|
+
app: {
|
|
1108
|
+
...app,
|
|
1109
|
+
status: "running",
|
|
1110
|
+
containers: appContainers.length,
|
|
1111
|
+
},
|
|
1112
|
+
requestId,
|
|
1113
|
+
})
|
|
1114
|
+
);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// No containers exist - run the app for the first time
|
|
1119
|
+
if (app.source === "backstage" && app.nodes && app.nodes.length > 0) {
|
|
1120
|
+
// Convert Backstage app to YAML and run
|
|
1121
|
+
const yamlContent = convertNodesToYAML(app);
|
|
1122
|
+
await runBackstageApp(ws, app, yamlContent, requestId);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// No containers and not a runnable app
|
|
1127
|
+
throw new Error("No containers found for this app. Unable to start.");
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
console.error("Error starting app:", error.message || error);
|
|
1130
|
+
ws.send(
|
|
1131
|
+
JSON.stringify({
|
|
1132
|
+
type: "error",
|
|
1133
|
+
error: "Failed to start app: " + (error.message || String(error)),
|
|
1134
|
+
requestId: payload.requestId,
|
|
1135
|
+
})
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function handleStopApp(ws, payload) {
|
|
1141
|
+
try {
|
|
1142
|
+
const { id, requestId } = payload;
|
|
1143
|
+
|
|
1144
|
+
// Extract user context from payload
|
|
1145
|
+
const userEntityRef = payload.userEntityRef || null;
|
|
1146
|
+
|
|
1147
|
+
// Find the app
|
|
1148
|
+
const apps = await findApps(userEntityRef);
|
|
1149
|
+
const app = apps.find((a) => a.id === id);
|
|
1150
|
+
|
|
1151
|
+
if (!app) {
|
|
1152
|
+
throw new Error("App not found");
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Normalize app name for container matching
|
|
1156
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1157
|
+
|
|
1158
|
+
// Find containers belonging to this app
|
|
1159
|
+
const containers = await docker.listContainers({ all: true });
|
|
1160
|
+
const appContainers = containers.filter((container) => {
|
|
1161
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
1162
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
1163
|
+
|
|
1164
|
+
return (
|
|
1165
|
+
containerName.startsWith(appName) ||
|
|
1166
|
+
projectLabel === appName ||
|
|
1167
|
+
container.Labels["app"] === appName
|
|
1168
|
+
);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// Get version from container labels (the actual running version)
|
|
1172
|
+
const runningVersion =
|
|
1173
|
+
appContainers.length > 0 && appContainers[0].Labels["app.version"]
|
|
1174
|
+
? appContainers[0].Labels["app.version"]
|
|
1175
|
+
: app.version || "1.0.0";
|
|
1176
|
+
const isDraft = app.backstageStatus === "draft";
|
|
1177
|
+
|
|
1178
|
+
// Log with appropriate format for draft vs published
|
|
1179
|
+
if (isDraft) {
|
|
1180
|
+
console.log(`⏹️ Stopping draft "${app.name}"...`);
|
|
1181
|
+
} else {
|
|
1182
|
+
console.log(
|
|
1183
|
+
`⏹️ Stopping application "${app.name} (${runningVersion})"...`
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Stop all containers
|
|
1188
|
+
for (const container of appContainers) {
|
|
1189
|
+
if (container.State === "running") {
|
|
1190
|
+
await docker.getContainer(container.Id).stop();
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Success log with appropriate format
|
|
1195
|
+
if (isDraft) {
|
|
1196
|
+
console.log(`✅ Draft "${app.name}" stopped successfully!`);
|
|
1197
|
+
} else {
|
|
1198
|
+
console.log(
|
|
1199
|
+
`✅ Application "${app.name} (${runningVersion})" stopped successfully!`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
ws.send(
|
|
1204
|
+
JSON.stringify({
|
|
1205
|
+
type: "appStopped",
|
|
1206
|
+
app: {
|
|
1207
|
+
...app,
|
|
1208
|
+
status: "stopped",
|
|
1209
|
+
},
|
|
1210
|
+
requestId,
|
|
1211
|
+
})
|
|
1212
|
+
);
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
console.error("Error stopping app:", error);
|
|
1215
|
+
ws.send(
|
|
1216
|
+
JSON.stringify({
|
|
1217
|
+
type: "error",
|
|
1218
|
+
error: "Failed to stop app: " + error.message,
|
|
1219
|
+
requestId: payload.requestId,
|
|
1220
|
+
})
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function handleRestartApp(ws, payload) {
|
|
1226
|
+
try {
|
|
1227
|
+
const { id, requestId } = payload;
|
|
1228
|
+
|
|
1229
|
+
// Extract user context from payload
|
|
1230
|
+
const userEntityRef = payload.userEntityRef || null;
|
|
1231
|
+
|
|
1232
|
+
// Find the app
|
|
1233
|
+
const apps = await findApps(userEntityRef);
|
|
1234
|
+
const app = apps.find((a) => a.id === id);
|
|
1235
|
+
|
|
1236
|
+
if (!app) {
|
|
1237
|
+
throw new Error("App not found");
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Normalize app name for container matching
|
|
1241
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1242
|
+
|
|
1243
|
+
// Find containers belonging to this app
|
|
1244
|
+
const containers = await docker.listContainers({ all: true });
|
|
1245
|
+
const appContainers = containers.filter((container) => {
|
|
1246
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
1247
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
1248
|
+
|
|
1249
|
+
return (
|
|
1250
|
+
containerName.startsWith(appName) ||
|
|
1251
|
+
projectLabel === appName ||
|
|
1252
|
+
container.Labels["app"] === appName
|
|
1253
|
+
);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
// Get version from container labels (the actual running version)
|
|
1257
|
+
const runningVersion =
|
|
1258
|
+
appContainers.length > 0 && appContainers[0].Labels["app.version"]
|
|
1259
|
+
? appContainers[0].Labels["app.version"]
|
|
1260
|
+
: app.version || "1.0.0";
|
|
1261
|
+
const isDraft = app.backstageStatus === "draft";
|
|
1262
|
+
|
|
1263
|
+
// Log with appropriate format for draft vs published
|
|
1264
|
+
if (isDraft) {
|
|
1265
|
+
console.log(`🔄 Restarting draft "${app.name}"...`);
|
|
1266
|
+
} else {
|
|
1267
|
+
console.log(
|
|
1268
|
+
`🔄 Restarting application "${app.name} (${runningVersion})"...`
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Restart all containers
|
|
1273
|
+
for (const container of appContainers) {
|
|
1274
|
+
await docker.getContainer(container.Id).restart();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Success log with appropriate format
|
|
1278
|
+
if (isDraft) {
|
|
1279
|
+
console.log(`✅ Draft "${app.name}" restarted successfully!`);
|
|
1280
|
+
} else {
|
|
1281
|
+
console.log(
|
|
1282
|
+
`✅ Application "${app.name} (${runningVersion})" restarted successfully!`
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
ws.send(
|
|
1287
|
+
JSON.stringify({
|
|
1288
|
+
type: "appRestarted",
|
|
1289
|
+
app: {
|
|
1290
|
+
...app,
|
|
1291
|
+
status: "running",
|
|
1292
|
+
containers: appContainers.length,
|
|
1293
|
+
},
|
|
1294
|
+
requestId,
|
|
1295
|
+
})
|
|
1296
|
+
);
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
console.error("Error restarting app:", error);
|
|
1299
|
+
ws.send(
|
|
1300
|
+
JSON.stringify({
|
|
1301
|
+
type: "error",
|
|
1302
|
+
error: "Failed to restart app: " + error.message,
|
|
1303
|
+
requestId: payload.requestId,
|
|
1304
|
+
})
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
async function handleDeleteApp(ws, payload) {
|
|
1310
|
+
try {
|
|
1311
|
+
const { id, requestId } = payload;
|
|
1312
|
+
|
|
1313
|
+
// Extract user context from payload
|
|
1314
|
+
const userEntityRef = payload.userEntityRef || null;
|
|
1315
|
+
|
|
1316
|
+
// Find the app
|
|
1317
|
+
const apps = await findApps(userEntityRef);
|
|
1318
|
+
const app = apps.find((a) => a.id === id);
|
|
1319
|
+
|
|
1320
|
+
if (!app) {
|
|
1321
|
+
throw new Error("App not found");
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const appVersion = app.version || "1.0.0";
|
|
1325
|
+
const appId = app.backstageId || app.id;
|
|
1326
|
+
const isDraft = app.backstageStatus === "draft";
|
|
1327
|
+
|
|
1328
|
+
// Normalize app name for container matching
|
|
1329
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1330
|
+
|
|
1331
|
+
// Find containers belonging to this app
|
|
1332
|
+
const containers = await docker.listContainers({ all: true });
|
|
1333
|
+
const appContainers = containers.filter((container) => {
|
|
1334
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
1335
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
1336
|
+
|
|
1337
|
+
return (
|
|
1338
|
+
containerName.startsWith(appName) ||
|
|
1339
|
+
projectLabel === appName ||
|
|
1340
|
+
container.Labels["app"] === appName
|
|
1341
|
+
);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
// Remove all containers
|
|
1345
|
+
for (const container of appContainers) {
|
|
1346
|
+
await docker.getContainer(container.Id).remove({ force: true });
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Delete workspace folder - use "draft" for drafts, version for published
|
|
1350
|
+
const workspacePath = isDraft
|
|
1351
|
+
? resolveWorkspacePathForDraft(appId, appName, "delete")
|
|
1352
|
+
: resolveWorkspacePath(appId, appName, appVersion, "delete");
|
|
1353
|
+
const registryVersion = isDraft ? "draft" : appVersion;
|
|
1354
|
+
|
|
1355
|
+
if (fs.existsSync(workspacePath)) {
|
|
1356
|
+
deleteWorkspaceFolder(workspacePath);
|
|
1357
|
+
unregisterWorkspace(appId, registryVersion);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// If app is from backstage, delete from backend database
|
|
1361
|
+
if (app.source === "backstage" && app.backstageId) {
|
|
1362
|
+
// Log with appropriate format for draft vs published
|
|
1363
|
+
if (isDraft) {
|
|
1364
|
+
console.log(`🗑️ Deleting draft "${app.name}"...`);
|
|
1365
|
+
} else {
|
|
1366
|
+
console.log(
|
|
1367
|
+
`🗑️ Deleting application "${app.name} (${appVersion})"...`
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const session = loadSession();
|
|
1372
|
+
if (!session || !session.token) {
|
|
1373
|
+
console.error(
|
|
1374
|
+
chalk.red("❌ No active session found. Cannot delete from database.")
|
|
1375
|
+
);
|
|
1376
|
+
throw new Error("No active session. Please login first.");
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const deleteUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${app.backstageId}`;
|
|
1380
|
+
|
|
1381
|
+
try {
|
|
1382
|
+
await axios.delete(deleteUrl, {
|
|
1383
|
+
headers: {
|
|
1384
|
+
Authorization: `Bearer ${session.token}`,
|
|
1385
|
+
"Content-Type": "application/json",
|
|
1386
|
+
},
|
|
1387
|
+
timeout: 10000,
|
|
1388
|
+
});
|
|
1389
|
+
// Success log with appropriate format
|
|
1390
|
+
if (isDraft) {
|
|
1391
|
+
console.log(`✅ Draft "${app.name}" deleted successfully!`);
|
|
1392
|
+
} else {
|
|
1393
|
+
console.log(
|
|
1394
|
+
`✅ Application "${app.name} (${appVersion})" deleted successfully!`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
} catch (dbError) {
|
|
1398
|
+
console.error(
|
|
1399
|
+
chalk.red("❌ Error deleting from database: ", dbError.message)
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
// If it's an authentication error, throw it so the user knows
|
|
1403
|
+
if (dbError.response?.status === 401) {
|
|
1404
|
+
throw new Error(
|
|
1405
|
+
"Authentication failed. Please login again with: fenwave login"
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
ws.send(
|
|
1412
|
+
JSON.stringify({
|
|
1413
|
+
type: "appDeleted",
|
|
1414
|
+
id,
|
|
1415
|
+
success: true,
|
|
1416
|
+
requestId,
|
|
1417
|
+
})
|
|
1418
|
+
);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
console.error("Error deleting app:", error);
|
|
1421
|
+
ws.send(
|
|
1422
|
+
JSON.stringify({
|
|
1423
|
+
type: "error",
|
|
1424
|
+
error: "Failed to delete app: " + error.message,
|
|
1425
|
+
requestId: payload.requestId,
|
|
1426
|
+
})
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Handle deletion of specific app versions
|
|
1433
|
+
*/
|
|
1434
|
+
async function handleDeleteAppVersions(ws, payload) {
|
|
1435
|
+
try {
|
|
1436
|
+
const { appName, backstageId, versions, requestId } = payload;
|
|
1437
|
+
|
|
1438
|
+
if (!appName || !versions || versions.length === 0) {
|
|
1439
|
+
throw new Error("appName and versions are required");
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const normalizedAppName = appName
|
|
1443
|
+
.replace(/[^a-z0-9-]/gi, "-")
|
|
1444
|
+
.toLowerCase();
|
|
1445
|
+
const appId = backstageId || appName;
|
|
1446
|
+
|
|
1447
|
+
const versionWord = versions.length === 1 ? "version" : "versions";
|
|
1448
|
+
|
|
1449
|
+
const deletedVersions = [];
|
|
1450
|
+
const failedVersions = [];
|
|
1451
|
+
|
|
1452
|
+
for (const version of versions) {
|
|
1453
|
+
const isDraft = version === "draft";
|
|
1454
|
+
|
|
1455
|
+
try {
|
|
1456
|
+
if (isDraft) {
|
|
1457
|
+
console.log(`🗑️ Deleting draft "${appName}"...`);
|
|
1458
|
+
} else {
|
|
1459
|
+
console.log(`🗑️ Deleting version ${version} of "${appName}"...`);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// Find containers for this specific version
|
|
1463
|
+
const containers = await docker.listContainers({ all: true });
|
|
1464
|
+
const versionContainers = containers.filter((container) => {
|
|
1465
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
1466
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
1467
|
+
const containerVersion = container.Labels["app.version"];
|
|
1468
|
+
|
|
1469
|
+
const matchesApp =
|
|
1470
|
+
containerName.startsWith(normalizedAppName) ||
|
|
1471
|
+
projectLabel === normalizedAppName ||
|
|
1472
|
+
container.Labels["app"] === normalizedAppName;
|
|
1473
|
+
|
|
1474
|
+
if (!matchesApp) return false;
|
|
1475
|
+
|
|
1476
|
+
// For drafts, match containers without version label or with draft project name
|
|
1477
|
+
if (isDraft) {
|
|
1478
|
+
return !containerVersion || projectLabel === normalizedAppName;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// If container has version label, match by version
|
|
1482
|
+
if (containerVersion) {
|
|
1483
|
+
const matchesVersion = containerVersion === version;
|
|
1484
|
+
return matchesVersion;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// If no version label, also match by project name suffix (e.g., app-name-v1.0.0)
|
|
1488
|
+
// This handles containers created before version labels were added
|
|
1489
|
+
const matchesByName =
|
|
1490
|
+
projectLabel === `${normalizedAppName}-${version}` ||
|
|
1491
|
+
containerName.includes(`-${version}`);
|
|
1492
|
+
return matchesByName;
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Remove containers for this version (force remove even if running)
|
|
1496
|
+
for (const container of versionContainers) {
|
|
1497
|
+
try {
|
|
1498
|
+
await docker.getContainer(container.Id).remove({ force: true });
|
|
1499
|
+
} catch (removeErr) {
|
|
1500
|
+
console.warn(
|
|
1501
|
+
` ⚠️ Failed to remove container ${container.Names[0]}: ${removeErr.message}`
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Delete workspace folder for this version
|
|
1507
|
+
// For drafts, use the draft workspace path
|
|
1508
|
+
const workspacePath = isDraft
|
|
1509
|
+
? resolveWorkspacePathForDraft(appId, normalizedAppName, "delete")
|
|
1510
|
+
: resolveWorkspacePath(appId, normalizedAppName, version, "delete");
|
|
1511
|
+
const registryKey = isDraft ? "draft" : version;
|
|
1512
|
+
|
|
1513
|
+
if (fs.existsSync(workspacePath)) {
|
|
1514
|
+
deleteWorkspaceFolder(workspacePath);
|
|
1515
|
+
unregisterWorkspace(appId, registryKey);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (isDraft) {
|
|
1519
|
+
console.log(`✅ Draft deleted successfully`);
|
|
1520
|
+
} else {
|
|
1521
|
+
console.log(`✅ Version ${version} deleted successfully`);
|
|
1522
|
+
}
|
|
1523
|
+
deletedVersions.push(version);
|
|
1524
|
+
} catch (versionError) {
|
|
1525
|
+
console.error(
|
|
1526
|
+
chalk.red(
|
|
1527
|
+
` ❌ Failed to delete version ${version}:`,
|
|
1528
|
+
versionError.message
|
|
1529
|
+
)
|
|
1530
|
+
);
|
|
1531
|
+
failedVersions.push({ version, error: versionError.message });
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
ws.send(
|
|
1536
|
+
JSON.stringify({
|
|
1537
|
+
type: "appVersionsDeleted",
|
|
1538
|
+
appName,
|
|
1539
|
+
backstageId,
|
|
1540
|
+
deletedVersions,
|
|
1541
|
+
failedVersions,
|
|
1542
|
+
success: failedVersions.length === 0,
|
|
1543
|
+
requestId,
|
|
1544
|
+
})
|
|
1545
|
+
);
|
|
1546
|
+
} catch (error) {
|
|
1547
|
+
console.error("Error deleting app versions:", error);
|
|
1548
|
+
ws.send(
|
|
1549
|
+
JSON.stringify({
|
|
1550
|
+
type: "error",
|
|
1551
|
+
error: "Failed to delete app versions: " + error.message,
|
|
1552
|
+
requestId: payload.requestId,
|
|
1553
|
+
})
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async function handleCreateApp(ws, payload) {
|
|
1559
|
+
try {
|
|
1560
|
+
const { templateName, yamlContent, requestId } = payload;
|
|
1561
|
+
const url = `${APP_BUILDER_URL}/api/app-builder/import-template`;
|
|
1562
|
+
try {
|
|
1563
|
+
await axios.post(
|
|
1564
|
+
url,
|
|
1565
|
+
{
|
|
1566
|
+
templateName,
|
|
1567
|
+
yamlContent,
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
headers: {
|
|
1571
|
+
"Content-Type": "application/json",
|
|
1572
|
+
},
|
|
1573
|
+
timeout: 10000,
|
|
1574
|
+
}
|
|
1575
|
+
);
|
|
1576
|
+
|
|
1577
|
+
ws.send(
|
|
1578
|
+
JSON.stringify({
|
|
1579
|
+
type: "appCreated",
|
|
1580
|
+
templateName,
|
|
1581
|
+
success: true,
|
|
1582
|
+
forwardedToComposer: true,
|
|
1583
|
+
requestId,
|
|
1584
|
+
})
|
|
1585
|
+
);
|
|
1586
|
+
} catch (forwardError) {
|
|
1587
|
+
console.error(
|
|
1588
|
+
chalk.red(
|
|
1589
|
+
"❌ Failed to forward template to app-builder:",
|
|
1590
|
+
forwardError.message
|
|
1591
|
+
)
|
|
1592
|
+
);
|
|
1593
|
+
|
|
1594
|
+
// Still log the template locally even if forwarding fails
|
|
1595
|
+
ws.send(
|
|
1596
|
+
JSON.stringify({
|
|
1597
|
+
type: "appCreated",
|
|
1598
|
+
templateName,
|
|
1599
|
+
success: true,
|
|
1600
|
+
forwardedToComposer: false,
|
|
1601
|
+
forwardError: forwardError.message,
|
|
1602
|
+
requestId,
|
|
1603
|
+
})
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
console.error("Error logging template:", error);
|
|
1608
|
+
ws.send(
|
|
1609
|
+
JSON.stringify({
|
|
1610
|
+
type: "error",
|
|
1611
|
+
error: "Failed to log template: " + error.message,
|
|
1612
|
+
requestId: payload.requestId,
|
|
1613
|
+
})
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Helper function to create a new bind mount (file or directory)
|
|
1620
|
+
* @param {string} appDownloadDir - The app workspace directory
|
|
1621
|
+
* @param {object} bindMount - The bind mount configuration
|
|
1622
|
+
* @param {string} baseName - The base name extracted from container path
|
|
1623
|
+
* @param {boolean} isDirectory - Whether this is a directory mount
|
|
1624
|
+
* @param {string} randomSuffix - Random suffix for the file/directory name
|
|
1625
|
+
* @returns {{hostPath: string, log: string}} The relative host path for docker-compose and log message
|
|
1626
|
+
*/
|
|
1627
|
+
function createNewBindMount(
|
|
1628
|
+
appDownloadDir,
|
|
1629
|
+
bindMount,
|
|
1630
|
+
baseName,
|
|
1631
|
+
isDirectory,
|
|
1632
|
+
randomSuffix
|
|
1633
|
+
) {
|
|
1634
|
+
if (isDirectory) {
|
|
1635
|
+
// For directory mounts, create a directory and a file inside it
|
|
1636
|
+
const hostDirName = `${baseName}.${randomSuffix}`;
|
|
1637
|
+
const hostDirPath = path.join(appDownloadDir, hostDirName);
|
|
1638
|
+
|
|
1639
|
+
// Create the directory
|
|
1640
|
+
if (!fs.existsSync(hostDirPath)) {
|
|
1641
|
+
fs.mkdirSync(hostDirPath, { recursive: true });
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Create a default file inside the directory
|
|
1645
|
+
const fileName = `${baseName}.toml`;
|
|
1646
|
+
const hostFilePath = path.join(hostDirPath, fileName);
|
|
1647
|
+
fs.writeFileSync(hostFilePath, bindMount.content, "utf8");
|
|
1648
|
+
const log = `📁 Created bind mount directory: ${hostDirName}/ with file: ${fileName}`;
|
|
1649
|
+
|
|
1650
|
+
return { hostPath: `./${hostDirName}`, log };
|
|
1651
|
+
} else {
|
|
1652
|
+
// For file mounts, create the file directly
|
|
1653
|
+
const hostFileName = `${baseName}.${randomSuffix}`;
|
|
1654
|
+
const hostFilePath = path.join(appDownloadDir, hostFileName);
|
|
1655
|
+
|
|
1656
|
+
// Write the content to the file
|
|
1657
|
+
fs.writeFileSync(hostFilePath, bindMount.content, "utf8");
|
|
1658
|
+
const log = `📝 Created bind mount file: ${hostFileName}`;
|
|
1659
|
+
|
|
1660
|
+
return { hostPath: `./${hostFileName}`, log };
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function generateDockerCompose(
|
|
1665
|
+
appDefinition,
|
|
1666
|
+
outputPath,
|
|
1667
|
+
dockerComposeConfig = null
|
|
1668
|
+
) {
|
|
1669
|
+
if (!appDefinition.application) {
|
|
1670
|
+
throw new Error('Invalid YAML: missing "application" key');
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const name = appDefinition.application.name;
|
|
1674
|
+
const version = appDefinition.application.version || "1.0.0";
|
|
1675
|
+
|
|
1676
|
+
if (!name || typeof name !== "string") {
|
|
1677
|
+
console.error(chalk.red("❌ Invalid application name:", name));
|
|
1678
|
+
throw new Error(
|
|
1679
|
+
`Invalid application name: expected string, got ${typeof name}`
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const services = {};
|
|
1684
|
+
const volumes = {};
|
|
1685
|
+
const networks = {};
|
|
1686
|
+
|
|
1687
|
+
for (const component of appDefinition.application.components) {
|
|
1688
|
+
const serviceName = component.name.toLowerCase();
|
|
1689
|
+
const service = {};
|
|
1690
|
+
|
|
1691
|
+
// Handle image or build context
|
|
1692
|
+
if (component.docker?.build && component.docker?.dockerfile) {
|
|
1693
|
+
service.build = {
|
|
1694
|
+
context: ".",
|
|
1695
|
+
dockerfile_inline: component.docker.dockerfile
|
|
1696
|
+
.trim()
|
|
1697
|
+
.replace(/\\n/g, "\n"),
|
|
1698
|
+
};
|
|
1699
|
+
} else if (component.docker?.image) {
|
|
1700
|
+
service.image = component.docker.image;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// container_name
|
|
1704
|
+
service.container_name = serviceName;
|
|
1705
|
+
|
|
1706
|
+
// Hostname
|
|
1707
|
+
if (component.hostname) {
|
|
1708
|
+
service.hostname = component.hostname;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Add labels to track the version
|
|
1712
|
+
service.labels = {
|
|
1713
|
+
"app.version": version,
|
|
1714
|
+
"app.name": name,
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
// Add custom labels if present
|
|
1718
|
+
if (component.labels && component.labels.length > 0) {
|
|
1719
|
+
component.labels.forEach((labelStr) => {
|
|
1720
|
+
if (typeof labelStr === "string" && labelStr.includes("=")) {
|
|
1721
|
+
const [key, ...valueParts] = labelStr.split("=");
|
|
1722
|
+
service.labels[key] = valueParts.join("=");
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// Ports - support multiple ports
|
|
1728
|
+
if (component.ports && Array.isArray(component.ports)) {
|
|
1729
|
+
// Multiple ports format - normalize single ports to host:container format
|
|
1730
|
+
service.ports = component.ports.map((p) => {
|
|
1731
|
+
const portStr = String(p);
|
|
1732
|
+
// If already in host:container format, use as-is; otherwise, map same port
|
|
1733
|
+
return portStr.includes(":") ? portStr : `${portStr}:${portStr}`;
|
|
1734
|
+
});
|
|
1735
|
+
} else if (component.port) {
|
|
1736
|
+
// Legacy single port support
|
|
1737
|
+
// For databases, map to their standard internal ports
|
|
1738
|
+
const standardPorts = {
|
|
1739
|
+
mysql: "3306",
|
|
1740
|
+
postgresql: "5432",
|
|
1741
|
+
mongo: "27017",
|
|
1742
|
+
redis: "6379",
|
|
1743
|
+
rabbitmq: "5672",
|
|
1744
|
+
elasticsearch: "9200",
|
|
1745
|
+
};
|
|
1746
|
+
|
|
1747
|
+
const internalPort = standardPorts[component.type] || component.port;
|
|
1748
|
+
service.ports = [`${component.port}:${internalPort}`];
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// Command - preserve user's format
|
|
1752
|
+
if (component.command && component.command.length > 0) {
|
|
1753
|
+
service.command = component.command;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Environment variables
|
|
1757
|
+
if (component.env) {
|
|
1758
|
+
service.environment = component.env;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// env_file - extract file names only, content will be written separately
|
|
1762
|
+
if (component.env_file && component.env_file.length > 0) {
|
|
1763
|
+
service.env_file = component.env_file.map((ef) =>
|
|
1764
|
+
typeof ef === "string" ? ef : `./${ef.name || ".env"}`
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Memory options
|
|
1769
|
+
if (component.mem_reservation) {
|
|
1770
|
+
service.mem_reservation = component.mem_reservation;
|
|
1771
|
+
}
|
|
1772
|
+
if (component.mem_limit) {
|
|
1773
|
+
service.mem_limit = component.mem_limit;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Apply global run config environment variables
|
|
1777
|
+
if (dockerComposeConfig && dockerComposeConfig.containerEnv) {
|
|
1778
|
+
service.environment = {
|
|
1779
|
+
...dockerComposeConfig.containerEnv,
|
|
1780
|
+
...(service.environment || {}),
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// depends_on (from manual configuration only)
|
|
1785
|
+
if (component.dependsOn?.length) {
|
|
1786
|
+
service.depends_on = component.dependsOn.map((dep) => dep.toLowerCase());
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Restart policy
|
|
1790
|
+
if (component.restartPolicy) {
|
|
1791
|
+
service.restart = component.restartPolicy;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// User (run container as specific user)
|
|
1795
|
+
if (component.user) {
|
|
1796
|
+
service.user = component.user;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Volumes
|
|
1800
|
+
if (component.volumes?.length) {
|
|
1801
|
+
service.volumes = component.volumes
|
|
1802
|
+
.filter((vol) => vol.name && (vol.mountPath || vol.mount_path))
|
|
1803
|
+
.map((vol) => {
|
|
1804
|
+
const mountPath = vol.mountPath || vol.mount_path;
|
|
1805
|
+
return `${vol.name}:${mountPath}`;
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
// Only add named volumes to top-level volumes (not bind mounts/host paths)
|
|
1809
|
+
component.volumes.forEach((vol) => {
|
|
1810
|
+
if (vol.name && !volumes[vol.name]) {
|
|
1811
|
+
// Skip bind mounts (paths starting with ./ or /)
|
|
1812
|
+
if (!vol.name.startsWith("./") && !vol.name.startsWith("/")) {
|
|
1813
|
+
volumes[vol.name] = { driver: "local" };
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Bind mounts with content - these will be handled separately
|
|
1820
|
+
// Store bind mounts for later file creation
|
|
1821
|
+
if (component.bindMounts?.length) {
|
|
1822
|
+
// Bind mounts will be processed after files are created
|
|
1823
|
+
// The _hostPath will be added to each bindMount during file creation
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Networks
|
|
1827
|
+
const builtinNetworks = ["bridge", "host", "none"];
|
|
1828
|
+
const componentNetworks = component.networks || [];
|
|
1829
|
+
|
|
1830
|
+
const builtinSelected = componentNetworks.filter((n) =>
|
|
1831
|
+
builtinNetworks.includes(n.name || n)
|
|
1832
|
+
);
|
|
1833
|
+
const customSelected = componentNetworks.filter(
|
|
1834
|
+
(n) => !builtinNetworks.includes(n.name || n)
|
|
1835
|
+
);
|
|
1836
|
+
|
|
1837
|
+
if (builtinSelected.length === 1 && componentNetworks.length === 1) {
|
|
1838
|
+
service.network_mode = builtinSelected[0].name || builtinSelected[0];
|
|
1839
|
+
} else if (componentNetworks.length === 0) {
|
|
1840
|
+
// default to bridge
|
|
1841
|
+
} else {
|
|
1842
|
+
// Handle custom networks with aliases support
|
|
1843
|
+
const serviceNetworks = {};
|
|
1844
|
+
customSelected.forEach((n) => {
|
|
1845
|
+
const name = typeof n === "string" ? n : n.name;
|
|
1846
|
+
const aliases =
|
|
1847
|
+
typeof n === "object" && n.aliases && n.aliases.length > 0
|
|
1848
|
+
? n.aliases
|
|
1849
|
+
: null;
|
|
1850
|
+
|
|
1851
|
+
if (aliases) {
|
|
1852
|
+
serviceNetworks[name] = { aliases };
|
|
1853
|
+
} else {
|
|
1854
|
+
serviceNetworks[name] = null;
|
|
1855
|
+
}
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
// If any network has aliases, use object format; otherwise use array
|
|
1859
|
+
const hasAliases = Object.values(serviceNetworks).some((v) => v !== null);
|
|
1860
|
+
if (hasAliases) {
|
|
1861
|
+
service.networks = serviceNetworks;
|
|
1862
|
+
} else {
|
|
1863
|
+
service.networks = customSelected.map((n) =>
|
|
1864
|
+
typeof n === "string" ? n : n.name
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// Define networks globally
|
|
1869
|
+
customSelected.forEach((n) => {
|
|
1870
|
+
const name = typeof n === "string" ? n : n.name;
|
|
1871
|
+
if (!networks[name]) {
|
|
1872
|
+
networks[name] = {
|
|
1873
|
+
driver: typeof n === "object" && n.driver ? n.driver : "bridge",
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Apply global run config resources and scaling
|
|
1880
|
+
if (dockerComposeConfig) {
|
|
1881
|
+
const deploy = generateComposeDeploy(
|
|
1882
|
+
dockerComposeConfig.resources,
|
|
1883
|
+
dockerComposeConfig.replicas
|
|
1884
|
+
);
|
|
1885
|
+
if (deploy) {
|
|
1886
|
+
service.deploy = deploy;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Process bind mounts - add to volumes array
|
|
1891
|
+
if (component.bindMounts?.length) {
|
|
1892
|
+
const bindMountVolumes = component.bindMounts
|
|
1893
|
+
.filter((bm) => {
|
|
1894
|
+
// For 'generated' mode, require _hostPath
|
|
1895
|
+
if (bm.mode === "generated" || !bm.mode) {
|
|
1896
|
+
return bm.containerPath && bm._hostPath;
|
|
1897
|
+
}
|
|
1898
|
+
// For 'existing' mode, require hostPath
|
|
1899
|
+
if (bm.mode === "existing") {
|
|
1900
|
+
return bm.containerPath && bm.hostPath;
|
|
1901
|
+
}
|
|
1902
|
+
return false;
|
|
1903
|
+
})
|
|
1904
|
+
.map((bm) => {
|
|
1905
|
+
const mountOption = bm.mountOption || "rw";
|
|
1906
|
+
|
|
1907
|
+
// Use _hostPath for generated mode, hostPath for existing mode
|
|
1908
|
+
const hostPath = bm.mode === "existing" ? bm.hostPath : bm._hostPath;
|
|
1909
|
+
|
|
1910
|
+
return `${hostPath}:${bm.containerPath}${
|
|
1911
|
+
mountOption === "ro" ? ":ro" : ""
|
|
1912
|
+
}`;
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
if (bindMountVolumes.length > 0) {
|
|
1916
|
+
service.volumes = [...(service.volumes || []), ...bindMountVolumes];
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// Final cleanup of undefined
|
|
1921
|
+
services[serviceName] = Object.fromEntries(
|
|
1922
|
+
Object.entries(service).filter(([, value]) => value !== undefined)
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const compose = {
|
|
1927
|
+
name,
|
|
1928
|
+
services,
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
// adding volumes in the root level if defined
|
|
1932
|
+
if (Object.keys(volumes).length > 0) {
|
|
1933
|
+
compose.volumes = volumes;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// adding networks in the root level if defined
|
|
1937
|
+
if (Object.keys(networks).length > 0) {
|
|
1938
|
+
compose.networks = networks;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const dumpOptions = {
|
|
1942
|
+
noRefs: true,
|
|
1943
|
+
lineWidth: -1,
|
|
1944
|
+
replacer: (key, value) => {
|
|
1945
|
+
if (key === "dockerfile_inline" && typeof value === "string") {
|
|
1946
|
+
return value;
|
|
1947
|
+
}
|
|
1948
|
+
return value;
|
|
1949
|
+
},
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
const yamlStr = yaml.dump(compose, dumpOptions);
|
|
1953
|
+
fs.writeFileSync(outputPath, yamlStr, "utf8");
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Convert Backstage app nodes to YAML format
|
|
1957
|
+
function convertNodesToYAML(app) {
|
|
1958
|
+
const components = app.nodes
|
|
1959
|
+
.filter((node) => node.type === "component")
|
|
1960
|
+
.map((node) => {
|
|
1961
|
+
const config = node.data.config || {};
|
|
1962
|
+
const component = {
|
|
1963
|
+
name: node.data.name,
|
|
1964
|
+
type: node.data.type,
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1967
|
+
// Build Docker image reference
|
|
1968
|
+
const registry = config.dockerRegistry || "";
|
|
1969
|
+
const repository = config.dockerRepository || "";
|
|
1970
|
+
const image = config.dockerImage || "";
|
|
1971
|
+
const tag = config.dockerTag || "latest";
|
|
1972
|
+
|
|
1973
|
+
if (image) {
|
|
1974
|
+
const fullImage = [registry, repository, image]
|
|
1975
|
+
.filter(Boolean)
|
|
1976
|
+
.join("/");
|
|
1977
|
+
component.docker = {
|
|
1978
|
+
image: `${fullImage}:${tag}`,
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
// Add ports (multiple ports support)
|
|
1983
|
+
if (config.ports && config.ports.length > 0) {
|
|
1984
|
+
component.ports = config.ports;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Add hostname
|
|
1988
|
+
if (config.hostname) {
|
|
1989
|
+
component.hostname = config.hostname;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Add commands
|
|
1993
|
+
if (config.commands && config.commands.length > 0) {
|
|
1994
|
+
component.command = config.commands;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Add labels
|
|
1998
|
+
if (config.labels && config.labels.length > 0) {
|
|
1999
|
+
component.labels = config.labels.map((l) => `${l.key}=${l.value}`);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Add env_file (with content to write)
|
|
2003
|
+
if (config.envFiles && config.envFiles.length > 0) {
|
|
2004
|
+
component.env_file = config.envFiles
|
|
2005
|
+
.filter((ef) => ef.content && ef.content.trim())
|
|
2006
|
+
.map((ef) => ({
|
|
2007
|
+
name: ef.name || ".env",
|
|
2008
|
+
content: ef.content,
|
|
2009
|
+
}));
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Add memory options
|
|
2013
|
+
if (config.memReservation) {
|
|
2014
|
+
component.mem_reservation = config.memReservation;
|
|
2015
|
+
}
|
|
2016
|
+
if (config.memLimit) {
|
|
2017
|
+
component.mem_limit = config.memLimit;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Add hostname
|
|
2021
|
+
if (config.hostname) {
|
|
2022
|
+
component.hostname = config.hostname;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// Add commands
|
|
2026
|
+
if (config.commands && config.commands.length > 0) {
|
|
2027
|
+
component.command = config.commands;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// Add labels
|
|
2031
|
+
if (config.labels && config.labels.length > 0) {
|
|
2032
|
+
component.labels = config.labels.map((l) => `${l.key}=${l.value}`);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Add env_file (with content to write)
|
|
2036
|
+
if (config.envFiles && config.envFiles.length > 0) {
|
|
2037
|
+
component.env_file = config.envFiles
|
|
2038
|
+
.filter((ef) => ef.content && ef.content.trim())
|
|
2039
|
+
.map((ef) => ({
|
|
2040
|
+
name: ef.name || ".env",
|
|
2041
|
+
content: ef.content,
|
|
2042
|
+
}));
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Add memory options
|
|
2046
|
+
if (config.memReservation) {
|
|
2047
|
+
component.mem_reservation = config.memReservation;
|
|
2048
|
+
}
|
|
2049
|
+
if (config.memLimit) {
|
|
2050
|
+
component.mem_limit = config.memLimit;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Add environment variables
|
|
2054
|
+
if (config.env && Object.keys(config.env).length > 0) {
|
|
2055
|
+
component.env = config.env;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// Add volumes
|
|
2059
|
+
if (config.volumeMounts && config.volumeMounts.length > 0) {
|
|
2060
|
+
component.volumes = config.volumeMounts.map((v) => ({
|
|
2061
|
+
name: v.name,
|
|
2062
|
+
mount_path: v.mountPath,
|
|
2063
|
+
size: v.size || "1Gi",
|
|
2064
|
+
}));
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// Add bind mounts with support for both modes
|
|
2068
|
+
if (config.bindMounts && config.bindMounts.length > 0) {
|
|
2069
|
+
component.bindMounts = config.bindMounts.map((bm) => {
|
|
2070
|
+
const baseMount = {
|
|
2071
|
+
containerPath: bm.containerPath,
|
|
2072
|
+
mountOption: bm.mountOption || "rw",
|
|
2073
|
+
mode: bm.mode || "generated",
|
|
2074
|
+
};
|
|
2075
|
+
|
|
2076
|
+
if (bm.mode === "existing" && bm.hostPath) {
|
|
2077
|
+
return {
|
|
2078
|
+
...baseMount,
|
|
2079
|
+
hostPath: bm.hostPath,
|
|
2080
|
+
};
|
|
2081
|
+
} else if ((bm.mode === "generated" || !bm.mode) && bm.content) {
|
|
2082
|
+
return {
|
|
2083
|
+
...baseMount,
|
|
2084
|
+
content: bm.content,
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// Fallback for legacy bind mounts
|
|
2089
|
+
return {
|
|
2090
|
+
...baseMount,
|
|
2091
|
+
content: bm.content || "",
|
|
2092
|
+
};
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
// Add networks
|
|
2097
|
+
if (config.networks && config.networks.length > 0) {
|
|
2098
|
+
component.networks = config.networks;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
// Add depends_on
|
|
2102
|
+
if (config.dependsOn && config.dependsOn.length > 0) {
|
|
2103
|
+
component.depends_on = config.dependsOn;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Add restart policy
|
|
2107
|
+
if (config.restartPolicy && config.restartPolicy !== "no") {
|
|
2108
|
+
component.restartPolicy = config.restartPolicy;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// Add user option
|
|
2112
|
+
// if (config.user) {
|
|
2113
|
+
// component.user = config.user;
|
|
2114
|
+
// }
|
|
2115
|
+
|
|
2116
|
+
return component;
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
const appDefinition = {
|
|
2120
|
+
application: {
|
|
2121
|
+
name: app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase(),
|
|
2122
|
+
version: app.version || "1.0.0",
|
|
2123
|
+
components: components,
|
|
2124
|
+
},
|
|
2125
|
+
};
|
|
2126
|
+
|
|
2127
|
+
return yaml.dump(appDefinition);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Run a Backstage app
|
|
2131
|
+
async function runBackstageApp(ws, app, yamlContent, requestId, context = {}) {
|
|
2132
|
+
const { isSync = false } = context;
|
|
2133
|
+
try {
|
|
2134
|
+
// Determine if this is a draft or published app
|
|
2135
|
+
const isDraft = app.backstageStatus === "draft" || app.status === "draft";
|
|
2136
|
+
const appVersion = app.version || "1.0.0";
|
|
2137
|
+
|
|
2138
|
+
// Log with appropriate format for draft vs published
|
|
2139
|
+
if (!isSync) {
|
|
2140
|
+
if (isDraft) {
|
|
2141
|
+
console.log(`🚀 Running draft "${app.name}"...`);
|
|
2142
|
+
} else {
|
|
2143
|
+
console.log(`🚀 Running application "${app.name} (${appVersion})"...`);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Send run started notification
|
|
2148
|
+
ws.send(
|
|
2149
|
+
JSON.stringify({
|
|
2150
|
+
type: "runStarted",
|
|
2151
|
+
requestId,
|
|
2152
|
+
})
|
|
2153
|
+
);
|
|
2154
|
+
|
|
2155
|
+
// Resolve workspace path (check registry or create new)
|
|
2156
|
+
const appId = app.backstageId || app.id;
|
|
2157
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
2158
|
+
// For drafts, don't include version in workspace path
|
|
2159
|
+
// Pass 'sync' context to suppress duplicate warning during sync
|
|
2160
|
+
const resolveContext = isSync ? "sync" : "default";
|
|
2161
|
+
const appDownloadDir = isDraft
|
|
2162
|
+
? resolveWorkspacePathForDraft(appId, appName, resolveContext)
|
|
2163
|
+
: resolveWorkspacePath(appId, appName, appVersion, resolveContext);
|
|
2164
|
+
const isNewWorkspace = !fs.existsSync(appDownloadDir);
|
|
2165
|
+
|
|
2166
|
+
// Parse YAML to extract run config
|
|
2167
|
+
const appDefinition = yaml.load(yamlContent);
|
|
2168
|
+
// Validate components have either an image or build context before proceeding
|
|
2169
|
+
const missingImageComponents = (
|
|
2170
|
+
appDefinition?.application?.components || []
|
|
2171
|
+
)
|
|
2172
|
+
.filter((component) => {
|
|
2173
|
+
const hasBuild = !!(
|
|
2174
|
+
component?.docker?.build && component?.docker?.dockerfile
|
|
2175
|
+
);
|
|
2176
|
+
const hasImage = !!component?.docker?.image;
|
|
2177
|
+
return !hasBuild && !hasImage;
|
|
2178
|
+
})
|
|
2179
|
+
.map((c) => c?.name)
|
|
2180
|
+
.filter(Boolean);
|
|
2181
|
+
|
|
2182
|
+
if (missingImageComponents.length > 0) {
|
|
2183
|
+
// Send a recognizable error that frontend will tailor into a toast
|
|
2184
|
+
const detail =
|
|
2185
|
+
missingImageComponents.length === 1
|
|
2186
|
+
? `Component "${missingImageComponents[0]}" has neither an image nor a build context specified`
|
|
2187
|
+
: `Components ${missingImageComponents
|
|
2188
|
+
.map((n) => `"${n}"`)
|
|
2189
|
+
.join(", ")} have neither an image nor a build context specified`;
|
|
2190
|
+
|
|
2191
|
+
ws.send(
|
|
2192
|
+
JSON.stringify({
|
|
2193
|
+
type: "error",
|
|
2194
|
+
error: detail,
|
|
2195
|
+
requestId,
|
|
2196
|
+
})
|
|
2197
|
+
);
|
|
2198
|
+
console.log(chalk.red(`❌ Run aborted: ${detail}`));
|
|
2199
|
+
|
|
2200
|
+
return; // Do not generate docker-compose or proceed further
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Now create the workspace directory (validation passed)
|
|
2204
|
+
if (!fs.existsSync(appDownloadDir)) {
|
|
2205
|
+
fs.mkdirSync(appDownloadDir, { recursive: true });
|
|
2206
|
+
// Log workspace creation immediately after it's created
|
|
2207
|
+
console.log(`📁 Created workspace at: ${appDownloadDir}`);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
const runConfig = appDefinition.application?.deploymentConfig;
|
|
2211
|
+
|
|
2212
|
+
// Transform run config to Docker Compose format
|
|
2213
|
+
let dockerComposeConfig = null;
|
|
2214
|
+
if (runConfig) {
|
|
2215
|
+
dockerComposeConfig = toDockerComposeConfig(runConfig);
|
|
2216
|
+
|
|
2217
|
+
// Generate host environment script if there are host env vars
|
|
2218
|
+
if (Object.keys(dockerComposeConfig.hostEnv).length > 0) {
|
|
2219
|
+
const hostEnvScript = generateHostEnvScript(
|
|
2220
|
+
dockerComposeConfig.hostEnv
|
|
2221
|
+
);
|
|
2222
|
+
const hostEnvPath = path.join(appDownloadDir, "host-env.sh");
|
|
2223
|
+
fs.writeFileSync(hostEnvPath, hostEnvScript, "utf8");
|
|
2224
|
+
fs.chmodSync(hostEnvPath, "755"); // Make executable
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// Generate .env file for Docker Compose
|
|
2228
|
+
if (
|
|
2229
|
+
Object.keys(dockerComposeConfig.containerEnv).length > 0 ||
|
|
2230
|
+
Object.keys(dockerComposeConfig.secretRefs).length > 0
|
|
2231
|
+
) {
|
|
2232
|
+
const envFileContent = generateEnvFile(
|
|
2233
|
+
dockerComposeConfig.containerEnv,
|
|
2234
|
+
dockerComposeConfig.secretRefs
|
|
2235
|
+
);
|
|
2236
|
+
const envFilePath = path.join(appDownloadDir, ".env");
|
|
2237
|
+
fs.writeFileSync(envFilePath, envFileContent, "utf8");
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// Write component-specific env files with content
|
|
2242
|
+
// Collect logs to display in proper order
|
|
2243
|
+
const envFileLogs = [];
|
|
2244
|
+
const bindMountCreatedLogs = [];
|
|
2245
|
+
const bindMountExistingLogs = [];
|
|
2246
|
+
|
|
2247
|
+
if (
|
|
2248
|
+
appDefinition &&
|
|
2249
|
+
appDefinition.application &&
|
|
2250
|
+
appDefinition.application.components
|
|
2251
|
+
) {
|
|
2252
|
+
for (const component of appDefinition.application.components) {
|
|
2253
|
+
if (component.env_file && Array.isArray(component.env_file)) {
|
|
2254
|
+
for (const envFile of component.env_file) {
|
|
2255
|
+
// Only write if it has content (new format)
|
|
2256
|
+
if (typeof envFile === "object" && envFile.content) {
|
|
2257
|
+
const envFileName = envFile.name || ".env";
|
|
2258
|
+
const envFilePath = path.join(appDownloadDir, envFileName);
|
|
2259
|
+
|
|
2260
|
+
// Check if env file already exists - if so, skip creation
|
|
2261
|
+
if (fs.existsSync(envFilePath)) {
|
|
2262
|
+
envFileLogs.push(`✅ Env file already exists: ${envFileName}`);
|
|
2263
|
+
} else {
|
|
2264
|
+
fs.writeFileSync(envFilePath, envFile.content, "utf8");
|
|
2265
|
+
envFileLogs.push(`📝 Created env file: ${envFileName}`);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// Write bind mount files with content or prepare existing paths
|
|
2272
|
+
if (component.bindMounts && Array.isArray(component.bindMounts)) {
|
|
2273
|
+
for (const bindMount of component.bindMounts) {
|
|
2274
|
+
if (!bindMount.containerPath) {
|
|
2275
|
+
console.warn(`⚠️ Skipping bind mount without containerPath`);
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const containerPath = bindMount.containerPath;
|
|
2280
|
+
const mode = bindMount.mode || "generated";
|
|
2281
|
+
|
|
2282
|
+
// Handle existing host path mode
|
|
2283
|
+
if (mode === "existing") {
|
|
2284
|
+
if (!bindMount.hostPath) {
|
|
2285
|
+
console.warn(
|
|
2286
|
+
`⚠️ Skipping bind mount in 'existing' mode without hostPath: ${containerPath}`
|
|
2287
|
+
);
|
|
2288
|
+
continue;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// Use the user-provided host path directly
|
|
2292
|
+
bindMount._hostPath = bindMount.hostPath;
|
|
2293
|
+
bindMountExistingLogs.push(
|
|
2294
|
+
`🔧 Using existing host path for ${containerPath} : ${bindMount.hostPath}`
|
|
2295
|
+
);
|
|
2296
|
+
continue;
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Handle generated content mode
|
|
2300
|
+
if (!bindMount.content) {
|
|
2301
|
+
console.warn(
|
|
2302
|
+
`⚠️ Skipping bind mount in 'generated' mode without content: ${containerPath}`
|
|
2303
|
+
);
|
|
2304
|
+
continue;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// Skip special system paths that should bind to host directly
|
|
2308
|
+
const systemPaths = [
|
|
2309
|
+
"/var/run/docker.sock",
|
|
2310
|
+
"/var/run/docker.sock.raw",
|
|
2311
|
+
];
|
|
2312
|
+
if (systemPaths.includes(containerPath)) {
|
|
2313
|
+
console.log(
|
|
2314
|
+
`⚠️ Skipping file creation for system path: ${containerPath} (will bind to host)`
|
|
2315
|
+
);
|
|
2316
|
+
bindMount._hostPath = containerPath;
|
|
2317
|
+
continue;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Determine if this is a file or directory based on the path
|
|
2321
|
+
const isDirectory =
|
|
2322
|
+
!path.extname(containerPath) || containerPath.endsWith("/");
|
|
2323
|
+
const baseName = path.basename(containerPath.replace(/\/$/, ""));
|
|
2324
|
+
|
|
2325
|
+
// Check if an existing bind mount with the same prefix already exists
|
|
2326
|
+
const existingFiles = fs.readdirSync(appDownloadDir);
|
|
2327
|
+
const existingMatch = existingFiles.find((file) => {
|
|
2328
|
+
// Match pattern: baseName.suffix
|
|
2329
|
+
const regex = new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i");
|
|
2330
|
+
return regex.test(file);
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
let hostPath;
|
|
2334
|
+
|
|
2335
|
+
if (existingMatch) {
|
|
2336
|
+
// Reuse existing bind mount
|
|
2337
|
+
const existingPath = path.join(appDownloadDir, existingMatch);
|
|
2338
|
+
const existingStat = fs.statSync(existingPath);
|
|
2339
|
+
|
|
2340
|
+
if (isDirectory && existingStat.isDirectory()) {
|
|
2341
|
+
// Check and update the file inside the directory
|
|
2342
|
+
const fileName = `${baseName}.toml`;
|
|
2343
|
+
const existingFilePath = path.join(existingPath, fileName);
|
|
2344
|
+
|
|
2345
|
+
try {
|
|
2346
|
+
// Check if content needs updating
|
|
2347
|
+
let needsUpdate = true;
|
|
2348
|
+
if (fs.existsSync(existingFilePath)) {
|
|
2349
|
+
const existingContent = fs.readFileSync(
|
|
2350
|
+
existingFilePath,
|
|
2351
|
+
"utf8"
|
|
2352
|
+
);
|
|
2353
|
+
needsUpdate = existingContent !== bindMount.content;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (needsUpdate) {
|
|
2357
|
+
fs.writeFileSync(
|
|
2358
|
+
existingFilePath,
|
|
2359
|
+
bindMount.content,
|
|
2360
|
+
"utf8"
|
|
2361
|
+
);
|
|
2362
|
+
bindMountCreatedLogs.push(
|
|
2363
|
+
`📎 Updated bind mount directory: ${existingMatch}/ with file: ${fileName}`
|
|
2364
|
+
);
|
|
2365
|
+
} else {
|
|
2366
|
+
bindMountCreatedLogs.push(
|
|
2367
|
+
`📎 Bind mount directory already up-to-date: ${existingMatch}/`
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
} catch (err) {
|
|
2371
|
+
// Permission denied ('ro' mount)
|
|
2372
|
+
console.log(
|
|
2373
|
+
`⚠️ Cannot update ${existingMatch}/ (${
|
|
2374
|
+
err.code || "permission denied"
|
|
2375
|
+
}), keeping existing content`
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
hostPath = `./${existingMatch}`;
|
|
2380
|
+
} else if (!isDirectory && existingStat.isFile()) {
|
|
2381
|
+
// Update the existing file
|
|
2382
|
+
try {
|
|
2383
|
+
let needsUpdate = true;
|
|
2384
|
+
const existingContent = fs.readFileSync(existingPath, "utf8");
|
|
2385
|
+
needsUpdate = existingContent !== bindMount.content;
|
|
2386
|
+
|
|
2387
|
+
if (needsUpdate) {
|
|
2388
|
+
fs.writeFileSync(existingPath, bindMount.content, "utf8");
|
|
2389
|
+
bindMountCreatedLogs.push(
|
|
2390
|
+
`📎 Updated bind mount file: ${existingMatch}`
|
|
2391
|
+
);
|
|
2392
|
+
} else {
|
|
2393
|
+
bindMountCreatedLogs.push(
|
|
2394
|
+
`📎 Bind mount file already up-to-date: ${existingMatch}`
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
console.log(
|
|
2399
|
+
`⚠️ Cannot update ${existingMatch} (${
|
|
2400
|
+
err.code || "permission denied"
|
|
2401
|
+
}), keeping existing content`
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
hostPath = `./${existingMatch}`;
|
|
2406
|
+
} else {
|
|
2407
|
+
// Type mismatch, create new
|
|
2408
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
2409
|
+
const result = createNewBindMount(
|
|
2410
|
+
appDownloadDir,
|
|
2411
|
+
bindMount,
|
|
2412
|
+
baseName,
|
|
2413
|
+
isDirectory,
|
|
2414
|
+
randomSuffix
|
|
2415
|
+
);
|
|
2416
|
+
hostPath = result.hostPath;
|
|
2417
|
+
bindMountCreatedLogs.push(result.log);
|
|
2418
|
+
}
|
|
2419
|
+
} else {
|
|
2420
|
+
// No existing match, create new bind mount
|
|
2421
|
+
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
2422
|
+
const result = createNewBindMount(
|
|
2423
|
+
appDownloadDir,
|
|
2424
|
+
bindMount,
|
|
2425
|
+
baseName,
|
|
2426
|
+
isDirectory,
|
|
2427
|
+
randomSuffix
|
|
2428
|
+
);
|
|
2429
|
+
hostPath = result.hostPath;
|
|
2430
|
+
bindMountCreatedLogs.push(result.log);
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// Store the generated host path for docker-compose generation (relative path)
|
|
2434
|
+
bindMount._hostPath = hostPath;
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
// Generate Docker Compose file directly
|
|
2441
|
+
const dockerComposePath = path.join(appDownloadDir, "docker-compose.yml");
|
|
2442
|
+
generateDockerCompose(
|
|
2443
|
+
appDefinition,
|
|
2444
|
+
dockerComposePath,
|
|
2445
|
+
dockerComposeConfig
|
|
2446
|
+
);
|
|
2447
|
+
console.log(`🐳 Docker Compose downloaded to: ${dockerComposePath}`);
|
|
2448
|
+
|
|
2449
|
+
// Now print collected logs in order
|
|
2450
|
+
for (const log of envFileLogs) {
|
|
2451
|
+
console.log(log);
|
|
2452
|
+
}
|
|
2453
|
+
for (const log of bindMountCreatedLogs) {
|
|
2454
|
+
console.log(log);
|
|
2455
|
+
}
|
|
2456
|
+
for (const log of bindMountExistingLogs) {
|
|
2457
|
+
console.log(log);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// Send docker compose generated notification
|
|
2461
|
+
ws.send(
|
|
2462
|
+
JSON.stringify({
|
|
2463
|
+
type: "dockerComposeGenerated",
|
|
2464
|
+
dockerComposePath,
|
|
2465
|
+
requestId,
|
|
2466
|
+
})
|
|
2467
|
+
);
|
|
2468
|
+
|
|
2469
|
+
// Build docker-compose command
|
|
2470
|
+
// If there's a host-env.sh, source it before running docker-compose
|
|
2471
|
+
let dockerComposeCommand;
|
|
2472
|
+
if (
|
|
2473
|
+
dockerComposeConfig &&
|
|
2474
|
+
Object.keys(dockerComposeConfig.hostEnv).length > 0
|
|
2475
|
+
) {
|
|
2476
|
+
dockerComposeCommand = `cd "${appDownloadDir}" && source ./host-env.sh && docker-compose up -d`;
|
|
2477
|
+
} else {
|
|
2478
|
+
dockerComposeCommand = `cd "${appDownloadDir}" && docker-compose up -d`;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
if (isSync) {
|
|
2482
|
+
if (isDraft) {
|
|
2483
|
+
console.log(`✅ Draft "${app.name}" synced successfully!`);
|
|
2484
|
+
} else {
|
|
2485
|
+
console.log(
|
|
2486
|
+
`✅ Application "${app.name} (${appVersion})" synced successfully!`
|
|
2487
|
+
);
|
|
2488
|
+
}
|
|
2489
|
+
if (isDraft) {
|
|
2490
|
+
console.log(`🚀 Running draft "${app.name}"...`);
|
|
2491
|
+
} else {
|
|
2492
|
+
console.log(`🚀 Running application "${app.name} (${appVersion})"...`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
exec(dockerComposeCommand, async (error, stdout, stderr) => {
|
|
2497
|
+
if (error) {
|
|
2498
|
+
// Clean up workspace folder if run failed and it was newly created
|
|
2499
|
+
if (isNewWorkspace && fs.existsSync(appDownloadDir)) {
|
|
2500
|
+
try {
|
|
2501
|
+
fs.rmSync(appDownloadDir, { recursive: true, force: true });
|
|
2502
|
+
console.log(`🧹 Cleaned up failed workspace: ${appDownloadDir}`);
|
|
2503
|
+
} catch (cleanupErr) {
|
|
2504
|
+
console.warn(
|
|
2505
|
+
`⚠️ Failed to clean up workspace: ${cleanupErr.message}`
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
ws.send(
|
|
2510
|
+
JSON.stringify({
|
|
2511
|
+
type: "error",
|
|
2512
|
+
error: `Docker Compose failed: ${error.message}`,
|
|
2513
|
+
stderr: stderr,
|
|
2514
|
+
requestId,
|
|
2515
|
+
})
|
|
2516
|
+
);
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
// Count the running containers
|
|
2521
|
+
const appName = app.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
2522
|
+
let containerCount = 0;
|
|
2523
|
+
try {
|
|
2524
|
+
const containers = await docker.listContainers({ all: true });
|
|
2525
|
+
const appContainers = containers.filter((container) => {
|
|
2526
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
2527
|
+
return (
|
|
2528
|
+
containerName.startsWith(appName) ||
|
|
2529
|
+
container.Labels["com.docker.compose.project"] === appName ||
|
|
2530
|
+
container.Labels["app"] === appName
|
|
2531
|
+
);
|
|
2532
|
+
});
|
|
2533
|
+
containerCount = appContainers.length;
|
|
2534
|
+
} catch (countError) {
|
|
2535
|
+
console.error("Error counting containers:", countError);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
if (isDraft) {
|
|
2539
|
+
console.log(`✅ Draft "${app.name}" ran successfully!`);
|
|
2540
|
+
} else {
|
|
2541
|
+
console.log(
|
|
2542
|
+
`✅ Application "${app.name} (${app.version})" ran successfully!`
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// Create workspace metadata on first successful run, or update if exists
|
|
2547
|
+
if (!isValidWorkspace(appDownloadDir)) {
|
|
2548
|
+
createWorkspaceMetadata(appDownloadDir, appId, app.name);
|
|
2549
|
+
// Register with "draft" key for drafts, or version for published apps
|
|
2550
|
+
const registryVersion = isDraft ? "draft" : appVersion;
|
|
2551
|
+
registerWorkspace(appId, app.name, registryVersion, appDownloadDir);
|
|
2552
|
+
} else {
|
|
2553
|
+
updateWorkspaceMetadata(appDownloadDir);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// Send success notification
|
|
2557
|
+
ws.send(
|
|
2558
|
+
JSON.stringify({
|
|
2559
|
+
type: "runCompleted",
|
|
2560
|
+
app: {
|
|
2561
|
+
...app,
|
|
2562
|
+
status: "running",
|
|
2563
|
+
containers: containerCount,
|
|
2564
|
+
},
|
|
2565
|
+
message: "Application ran successfully!",
|
|
2566
|
+
stdout: stdout || "Run completed",
|
|
2567
|
+
stderr: stderr || "",
|
|
2568
|
+
downloadPath: appDownloadDir,
|
|
2569
|
+
requestId,
|
|
2570
|
+
})
|
|
2571
|
+
);
|
|
2572
|
+
});
|
|
2573
|
+
} catch (error) {
|
|
2574
|
+
console.error(chalk.red("❌ Run error:", error));
|
|
2575
|
+
ws.send(
|
|
2576
|
+
JSON.stringify({
|
|
2577
|
+
type: "error",
|
|
2578
|
+
error: `Run failed: ${error.message}`,
|
|
2579
|
+
requestId,
|
|
2580
|
+
})
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/**
|
|
2586
|
+
* Validate Docker Compose configuration using real docker-compose CLI
|
|
2587
|
+
* This provides accurate validation against the local environment
|
|
2588
|
+
*/
|
|
2589
|
+
async function handleValidateCompose(ws, payload) {
|
|
2590
|
+
const { composeYaml, envVars, requestId } = payload;
|
|
2591
|
+
|
|
2592
|
+
console.log("🔍 Validating Docker Compose configuration...");
|
|
2593
|
+
|
|
2594
|
+
try {
|
|
2595
|
+
// Create temporary directory for validation using system temp directory
|
|
2596
|
+
const tempDir = path.join(os.tmpdir(), `fenwave-validate-${Date.now()}`);
|
|
2597
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
2598
|
+
|
|
2599
|
+
const composePath = path.join(tempDir, "docker-compose.yml");
|
|
2600
|
+
const envPath = path.join(tempDir, ".env");
|
|
2601
|
+
|
|
2602
|
+
try {
|
|
2603
|
+
// Write docker-compose.yml
|
|
2604
|
+
fs.writeFileSync(composePath, composeYaml, "utf-8");
|
|
2605
|
+
console.log(`✅ Written docker-compose.yml to ${composePath}`);
|
|
2606
|
+
|
|
2607
|
+
// Write .env file if provided
|
|
2608
|
+
if (envVars && Object.keys(envVars).length > 0) {
|
|
2609
|
+
const envContent = Object.entries(envVars)
|
|
2610
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
2611
|
+
.join("\n");
|
|
2612
|
+
fs.writeFileSync(envPath, envContent, "utf-8");
|
|
2613
|
+
console.log(
|
|
2614
|
+
`✅ Written .env file with ${Object.keys(envVars).length} variables`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
// Run docker-compose config to validate
|
|
2619
|
+
const command = `cd "${tempDir}" && docker-compose config --quiet`;
|
|
2620
|
+
|
|
2621
|
+
await new Promise((resolve, reject) => {
|
|
2622
|
+
exec(command, { timeout: 10000 }, (error, stdout, stderr) => {
|
|
2623
|
+
if (error) {
|
|
2624
|
+
// Docker Compose found errors
|
|
2625
|
+
console.error(
|
|
2626
|
+
chalk.red(
|
|
2627
|
+
"❌ Docker Compose validation failed:",
|
|
2628
|
+
stderr || error.message
|
|
2629
|
+
)
|
|
2630
|
+
);
|
|
2631
|
+
|
|
2632
|
+
// Send validation errors back to client
|
|
2633
|
+
ws.send(
|
|
2634
|
+
JSON.stringify({
|
|
2635
|
+
type: "validateCompose_result",
|
|
2636
|
+
requestId,
|
|
2637
|
+
data: {
|
|
2638
|
+
valid: false,
|
|
2639
|
+
errors: [stderr || error.message],
|
|
2640
|
+
warnings: [],
|
|
2641
|
+
},
|
|
2642
|
+
})
|
|
2643
|
+
);
|
|
2644
|
+
|
|
2645
|
+
reject(error);
|
|
2646
|
+
} else {
|
|
2647
|
+
console.log("✅ Docker Compose validation passed");
|
|
2648
|
+
|
|
2649
|
+
// Send success result
|
|
2650
|
+
ws.send(
|
|
2651
|
+
JSON.stringify({
|
|
2652
|
+
type: "validateCompose_result",
|
|
2653
|
+
requestId,
|
|
2654
|
+
data: {
|
|
2655
|
+
valid: true,
|
|
2656
|
+
errors: [],
|
|
2657
|
+
warnings: [],
|
|
2658
|
+
},
|
|
2659
|
+
})
|
|
2660
|
+
);
|
|
2661
|
+
|
|
2662
|
+
resolve();
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
});
|
|
2666
|
+
} finally {
|
|
2667
|
+
// Cleanup temporary files
|
|
2668
|
+
try {
|
|
2669
|
+
if (fs.existsSync(composePath)) {
|
|
2670
|
+
fs.unlinkSync(composePath);
|
|
2671
|
+
}
|
|
2672
|
+
if (fs.existsSync(envPath)) {
|
|
2673
|
+
fs.unlinkSync(envPath);
|
|
2674
|
+
}
|
|
2675
|
+
if (fs.existsSync(tempDir)) {
|
|
2676
|
+
fs.rmdirSync(tempDir);
|
|
2677
|
+
}
|
|
2678
|
+
console.log("🧹 Cleaned up temporary validation files");
|
|
2679
|
+
} catch (cleanupError) {
|
|
2680
|
+
console.warn("⚠️ Failed to cleanup temp files:", cleanupError.message);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
} catch (error) {
|
|
2684
|
+
console.error(chalk.red("❌ Validation error:", error));
|
|
2685
|
+
ws.send(
|
|
2686
|
+
JSON.stringify({
|
|
2687
|
+
type: "error",
|
|
2688
|
+
error: `Validation failed: ${error.message}`,
|
|
2689
|
+
requestId,
|
|
2690
|
+
})
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* Sync applications:
|
|
2697
|
+
* - Stops & removes existing containers, fetches latest app definition from Backstage,
|
|
2698
|
+
* regenerates docker-compose and runs the app (recreate all containers).
|
|
2699
|
+
* - Preserves the run state (running/stopped) from before sync.
|
|
2700
|
+
* - Uses non-destructive backup approach for docker-compose.yml
|
|
2701
|
+
*/
|
|
2702
|
+
async function handleSyncApp(ws, payload = {}) {
|
|
2703
|
+
const { backstageId, appData, id, requestId, keepRunning, changeId } =
|
|
2704
|
+
payload;
|
|
2705
|
+
try {
|
|
2706
|
+
// Extract backstageId from id if needed
|
|
2707
|
+
const actualBackstageId =
|
|
2708
|
+
backstageId ||
|
|
2709
|
+
(id && id.startsWith("backstage-") ? id.replace("backstage-", "") : id);
|
|
2710
|
+
|
|
2711
|
+
if (!actualBackstageId)
|
|
2712
|
+
throw new Error("backstageId or id required for sync");
|
|
2713
|
+
|
|
2714
|
+
ws.send(
|
|
2715
|
+
JSON.stringify({
|
|
2716
|
+
type: "syncProgress",
|
|
2717
|
+
requestId,
|
|
2718
|
+
message: "Starting sync...",
|
|
2719
|
+
progress: 10,
|
|
2720
|
+
})
|
|
2721
|
+
);
|
|
2722
|
+
|
|
2723
|
+
let latestApp;
|
|
2724
|
+
|
|
2725
|
+
// If appData is provided (from App Builder), use it directly
|
|
2726
|
+
if (appData) {
|
|
2727
|
+
ws.send(
|
|
2728
|
+
JSON.stringify({
|
|
2729
|
+
type: "syncProgress",
|
|
2730
|
+
requestId,
|
|
2731
|
+
message: "Processing app blueprint...",
|
|
2732
|
+
progress: 20,
|
|
2733
|
+
})
|
|
2734
|
+
);
|
|
2735
|
+
|
|
2736
|
+
latestApp = {
|
|
2737
|
+
id: `backstage-${actualBackstageId}`,
|
|
2738
|
+
backstageId: actualBackstageId,
|
|
2739
|
+
name: appData.name,
|
|
2740
|
+
nodes: appData.nodes,
|
|
2741
|
+
edges: appData.edges,
|
|
2742
|
+
description: appData.description,
|
|
2743
|
+
version: appData.version,
|
|
2744
|
+
status: appData.status,
|
|
2745
|
+
source: "backstage",
|
|
2746
|
+
};
|
|
2747
|
+
} else {
|
|
2748
|
+
// Fetch from Backstage API (called from DevApp)
|
|
2749
|
+
ws.send(
|
|
2750
|
+
JSON.stringify({
|
|
2751
|
+
type: "syncProgress",
|
|
2752
|
+
requestId,
|
|
2753
|
+
message: "Fetching app info from Backstage...",
|
|
2754
|
+
progress: 20,
|
|
2755
|
+
})
|
|
2756
|
+
);
|
|
2757
|
+
|
|
2758
|
+
const session = loadSession();
|
|
2759
|
+
if (!session || !session.token) {
|
|
2760
|
+
throw new Error("No valid session found. Please run: fenwave login");
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// Fetch app metadata first to get the app name
|
|
2764
|
+
const appUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${actualBackstageId}`;
|
|
2765
|
+
const appResponse = await axios.get(appUrl, {
|
|
2766
|
+
headers: {
|
|
2767
|
+
"Content-Type": "application/json",
|
|
2768
|
+
Authorization: `Bearer ${session.token}`,
|
|
2769
|
+
},
|
|
2770
|
+
timeout: 10000,
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
const fetchedApp = appResponse.data;
|
|
2774
|
+
|
|
2775
|
+
// Now check for existing containers using the ACTUAL app name
|
|
2776
|
+
const normalizedAppName = fetchedApp.name
|
|
2777
|
+
.replace(/[^a-z0-9-]/gi, "-")
|
|
2778
|
+
.toLowerCase();
|
|
2779
|
+
|
|
2780
|
+
const containers = await docker.listContainers({ all: true });
|
|
2781
|
+
const existingContainers = containers.filter((container) => {
|
|
2782
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
2783
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
2784
|
+
return (
|
|
2785
|
+
containerName.startsWith(normalizedAppName) ||
|
|
2786
|
+
projectLabel === normalizedAppName ||
|
|
2787
|
+
container.Labels["app"] === normalizedAppName
|
|
2788
|
+
);
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
let versionToFetch = null;
|
|
2792
|
+
if (
|
|
2793
|
+
existingContainers.length > 0 &&
|
|
2794
|
+
existingContainers[0].Labels["app.version"]
|
|
2795
|
+
) {
|
|
2796
|
+
versionToFetch = existingContainers[0].Labels["app.version"];
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// Fetch versions separately if we need a specific version
|
|
2800
|
+
let allVersions = [];
|
|
2801
|
+
if (versionToFetch) {
|
|
2802
|
+
try {
|
|
2803
|
+
const versionsUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${actualBackstageId}/versions`;
|
|
2804
|
+
const versionsResponse = await axios.get(versionsUrl, {
|
|
2805
|
+
headers: {
|
|
2806
|
+
"Content-Type": "application/json",
|
|
2807
|
+
Authorization: `Bearer ${session.token}`,
|
|
2808
|
+
},
|
|
2809
|
+
timeout: 10000,
|
|
2810
|
+
});
|
|
2811
|
+
allVersions = versionsResponse.data || [];
|
|
2812
|
+
} catch (err) {
|
|
2813
|
+
console.warn(`Failed to fetch versions: ${err.message}`);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// If we have a specific version to preserve, use that version's blueprint from the versions array
|
|
2818
|
+
let versionData = null;
|
|
2819
|
+
if (versionToFetch && allVersions.length > 0) {
|
|
2820
|
+
versionData = allVersions.find((v) => v.version === versionToFetch);
|
|
2821
|
+
|
|
2822
|
+
if (versionData) {
|
|
2823
|
+
ws.send(
|
|
2824
|
+
JSON.stringify({
|
|
2825
|
+
type: "syncProgress",
|
|
2826
|
+
requestId,
|
|
2827
|
+
message: `Using version ${versionToFetch} blueprint...`,
|
|
2828
|
+
progress: 25,
|
|
2829
|
+
})
|
|
2830
|
+
);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Use version-specific data if available, otherwise use active version
|
|
2835
|
+
const blueprintSource =
|
|
2836
|
+
versionData || fetchedApp.activeVersion || fetchedApp;
|
|
2837
|
+
const finalVersion =
|
|
2838
|
+
versionToFetch ||
|
|
2839
|
+
blueprintSource.version ||
|
|
2840
|
+
fetchedApp.activeVersion?.version ||
|
|
2841
|
+
"1.0.0";
|
|
2842
|
+
|
|
2843
|
+
latestApp = {
|
|
2844
|
+
id: `backstage-${actualBackstageId}`,
|
|
2845
|
+
backstageId: actualBackstageId,
|
|
2846
|
+
name: fetchedApp.name,
|
|
2847
|
+
nodes: blueprintSource.nodes || [],
|
|
2848
|
+
edges: blueprintSource.edges || [],
|
|
2849
|
+
description: blueprintSource.description || "",
|
|
2850
|
+
version: finalVersion,
|
|
2851
|
+
status:
|
|
2852
|
+
fetchedApp.status || fetchedApp.activeVersion?.status || "draft",
|
|
2853
|
+
source: "backstage",
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// Resolve workspace path (use registered or create new)
|
|
2858
|
+
const appId = latestApp.backstageId || latestApp.id;
|
|
2859
|
+
const appVersion = latestApp.version || "1.0.0";
|
|
2860
|
+
const isDraft = latestApp.status === "draft";
|
|
2861
|
+
|
|
2862
|
+
if (isDraft) {
|
|
2863
|
+
console.log(`🚀 Syncing draft "${latestApp.name}"...`);
|
|
2864
|
+
} else {
|
|
2865
|
+
console.log(
|
|
2866
|
+
`🚀 Syncing application "${latestApp.name} (${appVersion})"...`
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const appDownloadDir = isDraft
|
|
2871
|
+
? resolveWorkspacePathForDraft(appId, latestApp.name)
|
|
2872
|
+
: resolveWorkspacePath(appId, latestApp.name, appVersion);
|
|
2873
|
+
|
|
2874
|
+
// Backup existing docker-compose.yml before regenerating
|
|
2875
|
+
ws.send(
|
|
2876
|
+
JSON.stringify({
|
|
2877
|
+
type: "syncProgress",
|
|
2878
|
+
requestId,
|
|
2879
|
+
message: "Backing up existing configuration...",
|
|
2880
|
+
progress: 28,
|
|
2881
|
+
})
|
|
2882
|
+
);
|
|
2883
|
+
|
|
2884
|
+
if (fs.existsSync(appDownloadDir)) {
|
|
2885
|
+
backupDockerCompose(appDownloadDir);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// Normalize app name for container matching
|
|
2889
|
+
const appName = latestApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
2890
|
+
|
|
2891
|
+
// Detect existing containers
|
|
2892
|
+
const containers = await docker.listContainers({ all: true });
|
|
2893
|
+
const appContainers = containers.filter((container) => {
|
|
2894
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
2895
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
2896
|
+
return (
|
|
2897
|
+
containerName.startsWith(appName) ||
|
|
2898
|
+
projectLabel === appName ||
|
|
2899
|
+
container.Labels["app"] === appName
|
|
2900
|
+
);
|
|
2901
|
+
});
|
|
2902
|
+
|
|
2903
|
+
// Record whether it was running
|
|
2904
|
+
const wasRunning = appContainers.some((c) => c.State === "running");
|
|
2905
|
+
|
|
2906
|
+
ws.send(
|
|
2907
|
+
JSON.stringify({
|
|
2908
|
+
type: "syncProgress",
|
|
2909
|
+
requestId,
|
|
2910
|
+
message: `Stopping ${appContainers.length} containers...`,
|
|
2911
|
+
progress: 30,
|
|
2912
|
+
})
|
|
2913
|
+
);
|
|
2914
|
+
|
|
2915
|
+
// Stop running containers
|
|
2916
|
+
for (const c of appContainers) {
|
|
2917
|
+
try {
|
|
2918
|
+
if (c.State === "running") {
|
|
2919
|
+
await docker.getContainer(c.Id).stop();
|
|
2920
|
+
}
|
|
2921
|
+
} catch (err) {
|
|
2922
|
+
console.warn("Error stopping container", c.Id, err.message);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
ws.send(
|
|
2927
|
+
JSON.stringify({
|
|
2928
|
+
type: "syncProgress",
|
|
2929
|
+
requestId,
|
|
2930
|
+
message: "Removing old containers...",
|
|
2931
|
+
progress: 50,
|
|
2932
|
+
})
|
|
2933
|
+
);
|
|
2934
|
+
|
|
2935
|
+
// Remove containers
|
|
2936
|
+
for (const c of appContainers) {
|
|
2937
|
+
try {
|
|
2938
|
+
await docker.getContainer(c.Id).remove({ force: true });
|
|
2939
|
+
} catch (err) {
|
|
2940
|
+
console.warn("Error removing container", c.Id, err.message);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// Prepare YAML for the latest version and run
|
|
2945
|
+
const yamlContent = convertNodesToYAML(latestApp);
|
|
2946
|
+
|
|
2947
|
+
ws.send(
|
|
2948
|
+
JSON.stringify({
|
|
2949
|
+
type: "syncProgress",
|
|
2950
|
+
requestId,
|
|
2951
|
+
message: "Regenerating compose and creating containers...",
|
|
2952
|
+
progress: 70,
|
|
2953
|
+
})
|
|
2954
|
+
);
|
|
2955
|
+
|
|
2956
|
+
// Reuse runBackstageApp flow to create docker-compose and run
|
|
2957
|
+
await runBackstageApp(ws, latestApp, yamlContent, requestId, {
|
|
2958
|
+
isSync: true,
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
ws.send(
|
|
2962
|
+
JSON.stringify({
|
|
2963
|
+
type: "syncProgress",
|
|
2964
|
+
requestId,
|
|
2965
|
+
message: "Restoring run state...",
|
|
2966
|
+
progress: 90,
|
|
2967
|
+
})
|
|
2968
|
+
);
|
|
2969
|
+
|
|
2970
|
+
// If original was stopped AND we're not doing auto-sync-before-start, stop the newly created containers
|
|
2971
|
+
// keepRunning flag indicates this sync is part of auto-sync-then-start flow
|
|
2972
|
+
if (!wasRunning && !keepRunning) {
|
|
2973
|
+
// Wait a moment for containers to fully start before stopping them
|
|
2974
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2975
|
+
|
|
2976
|
+
const postContainers = await docker.listContainers({ all: true });
|
|
2977
|
+
const newAppContainers = postContainers.filter((container) => {
|
|
2978
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
2979
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
2980
|
+
return (
|
|
2981
|
+
containerName.startsWith(appName) ||
|
|
2982
|
+
projectLabel === appName ||
|
|
2983
|
+
container.Labels["app"] === appName
|
|
2984
|
+
);
|
|
2985
|
+
});
|
|
2986
|
+
for (const c of newAppContainers) {
|
|
2987
|
+
if (c.State === "running") {
|
|
2988
|
+
try {
|
|
2989
|
+
await docker.getContainer(c.Id).stop();
|
|
2990
|
+
} catch (stopErr) {
|
|
2991
|
+
console.warn(
|
|
2992
|
+
"Failed to stop container after recreate",
|
|
2993
|
+
c.Id,
|
|
2994
|
+
stopErr.message
|
|
2995
|
+
);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
ws.send(
|
|
3000
|
+
JSON.stringify({
|
|
3001
|
+
type: "syncProgress",
|
|
3002
|
+
requestId,
|
|
3003
|
+
message: "Preserved stopped state",
|
|
3004
|
+
progress: 95,
|
|
3005
|
+
})
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
// Acknowledge the event to prevent it from being broadcasted again
|
|
3010
|
+
if (changeId) {
|
|
3011
|
+
await acknowledgeEvent(null, changeId);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
// Final success - determine final state
|
|
3015
|
+
const finalState = keepRunning || wasRunning ? "running" : "stopped";
|
|
3016
|
+
ws.send(
|
|
3017
|
+
JSON.stringify({
|
|
3018
|
+
type: "syncCompleted",
|
|
3019
|
+
requestId,
|
|
3020
|
+
app: { ...latestApp, status: finalState },
|
|
3021
|
+
message: "Sync completed successfully",
|
|
3022
|
+
progress: 100,
|
|
3023
|
+
})
|
|
3024
|
+
);
|
|
3025
|
+
|
|
3026
|
+
// Acknowledge the event after successful sync
|
|
3027
|
+
if (payload.eventId) {
|
|
3028
|
+
const ackSuccess = await acknowledgeEvent(
|
|
3029
|
+
payload.eventId,
|
|
3030
|
+
payload.changeId
|
|
3031
|
+
);
|
|
3032
|
+
if (ackSuccess) {
|
|
3033
|
+
console.log(`✅ Event ${payload.eventId} acknowledged successfully`);
|
|
3034
|
+
} else {
|
|
3035
|
+
console.warn(`⚠️ Failed to acknowledge event ${payload.eventId}`);
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
} catch (error) {
|
|
3039
|
+
console.error(chalk.red("❌ Sync failed:", error.message || error));
|
|
3040
|
+
ws.send(
|
|
3041
|
+
JSON.stringify({
|
|
3042
|
+
type: "syncFailed",
|
|
3043
|
+
requestId,
|
|
3044
|
+
error: error.message || String(error),
|
|
3045
|
+
})
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/**
|
|
3051
|
+
* Change app version:
|
|
3052
|
+
* - Stops & removes existing containers
|
|
3053
|
+
* - Fetches the specified version from Backstage
|
|
3054
|
+
* - Regenerates docker-compose and runs the app with the selected version
|
|
3055
|
+
*/
|
|
3056
|
+
async function handleChangeVersion(ws, payload = {}) {
|
|
3057
|
+
const { backstageId, id, targetVersion, requestId } = payload;
|
|
3058
|
+
|
|
3059
|
+
try {
|
|
3060
|
+
// Extract backstageId from id if needed
|
|
3061
|
+
const actualBackstageId =
|
|
3062
|
+
backstageId ||
|
|
3063
|
+
(id && id.startsWith("backstage-") ? id.replace("backstage-", "") : id);
|
|
3064
|
+
|
|
3065
|
+
if (!actualBackstageId) {
|
|
3066
|
+
throw new Error("backstageId or id required for version change");
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
if (!targetVersion) {
|
|
3070
|
+
throw new Error("targetVersion is required");
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
ws.send(
|
|
3074
|
+
JSON.stringify({
|
|
3075
|
+
type: "versionChangeProgress",
|
|
3076
|
+
requestId,
|
|
3077
|
+
message: "Starting version change...",
|
|
3078
|
+
progress: 10,
|
|
3079
|
+
})
|
|
3080
|
+
);
|
|
3081
|
+
|
|
3082
|
+
// Fetch app info from Backstage
|
|
3083
|
+
ws.send(
|
|
3084
|
+
JSON.stringify({
|
|
3085
|
+
type: "versionChangeProgress",
|
|
3086
|
+
requestId,
|
|
3087
|
+
message: "Fetching app info from Backstage...",
|
|
3088
|
+
progress: 15,
|
|
3089
|
+
})
|
|
3090
|
+
);
|
|
3091
|
+
|
|
3092
|
+
const session = loadSession();
|
|
3093
|
+
if (!session || !session.token) {
|
|
3094
|
+
throw new Error("No valid session found. Please run: fenwave login");
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// Fetch app metadata
|
|
3098
|
+
const appUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${actualBackstageId}`;
|
|
3099
|
+
const appResponse = await axios.get(appUrl, {
|
|
3100
|
+
headers: {
|
|
3101
|
+
"Content-Type": "application/json",
|
|
3102
|
+
Authorization: `Bearer ${session.token}`,
|
|
3103
|
+
},
|
|
3104
|
+
timeout: 10000,
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
const fetchedApp = appResponse.data;
|
|
3108
|
+
|
|
3109
|
+
// Fetch all versions
|
|
3110
|
+
ws.send(
|
|
3111
|
+
JSON.stringify({
|
|
3112
|
+
type: "versionChangeProgress",
|
|
3113
|
+
requestId,
|
|
3114
|
+
message: "Fetching target version...",
|
|
3115
|
+
progress: 20,
|
|
3116
|
+
})
|
|
3117
|
+
);
|
|
3118
|
+
|
|
3119
|
+
const versionsUrl = `${APP_BUILDER_URL}/api/app-builder/applications/${actualBackstageId}/versions`;
|
|
3120
|
+
const versionsResponse = await axios.get(versionsUrl, {
|
|
3121
|
+
headers: {
|
|
3122
|
+
"Content-Type": "application/json",
|
|
3123
|
+
Authorization: `Bearer ${session.token}`,
|
|
3124
|
+
},
|
|
3125
|
+
timeout: 10000,
|
|
3126
|
+
});
|
|
3127
|
+
|
|
3128
|
+
const allVersions = versionsResponse.data || [];
|
|
3129
|
+
|
|
3130
|
+
// Find the target version
|
|
3131
|
+
const versionData = allVersions.find((v) => v.version === targetVersion);
|
|
3132
|
+
|
|
3133
|
+
if (!versionData) {
|
|
3134
|
+
throw new Error(`Version ${targetVersion} not found`);
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
ws.send(
|
|
3138
|
+
JSON.stringify({
|
|
3139
|
+
type: "versionChangeProgress",
|
|
3140
|
+
requestId,
|
|
3141
|
+
message: `Switching to version ${targetVersion}...`,
|
|
3142
|
+
progress: 25,
|
|
3143
|
+
})
|
|
3144
|
+
);
|
|
3145
|
+
|
|
3146
|
+
// Build the app object with the target version's data
|
|
3147
|
+
const targetApp = {
|
|
3148
|
+
id: `backstage-${actualBackstageId}`,
|
|
3149
|
+
backstageId: actualBackstageId,
|
|
3150
|
+
name: fetchedApp.name,
|
|
3151
|
+
nodes: versionData.nodes || [],
|
|
3152
|
+
edges: versionData.edges || [],
|
|
3153
|
+
description: versionData.description || fetchedApp.description || "",
|
|
3154
|
+
version: targetVersion,
|
|
3155
|
+
status: versionData.status || "published",
|
|
3156
|
+
source: "backstage",
|
|
3157
|
+
};
|
|
3158
|
+
|
|
3159
|
+
// Normalize app name for container matching
|
|
3160
|
+
const appName = targetApp.name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
3161
|
+
|
|
3162
|
+
// Detect existing containers
|
|
3163
|
+
const containers = await docker.listContainers({ all: true });
|
|
3164
|
+
const appContainers = containers.filter((container) => {
|
|
3165
|
+
const containerName = container.Names[0].replace(/^\//, "");
|
|
3166
|
+
const projectLabel = container.Labels["com.docker.compose.project"];
|
|
3167
|
+
return (
|
|
3168
|
+
containerName.startsWith(appName) ||
|
|
3169
|
+
projectLabel === appName ||
|
|
3170
|
+
container.Labels["app"] === appName
|
|
3171
|
+
);
|
|
3172
|
+
});
|
|
3173
|
+
|
|
3174
|
+
ws.send(
|
|
3175
|
+
JSON.stringify({
|
|
3176
|
+
type: "versionChangeProgress",
|
|
3177
|
+
requestId,
|
|
3178
|
+
message: `Stopping ${appContainers.length} containers...`,
|
|
3179
|
+
progress: 30,
|
|
3180
|
+
})
|
|
3181
|
+
);
|
|
3182
|
+
|
|
3183
|
+
// Stop running containers
|
|
3184
|
+
for (const c of appContainers) {
|
|
3185
|
+
try {
|
|
3186
|
+
if (c.State === "running") {
|
|
3187
|
+
await docker.getContainer(c.Id).stop();
|
|
3188
|
+
}
|
|
3189
|
+
} catch (err) {
|
|
3190
|
+
console.warn("Error stopping container", c.Id, err.message);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
ws.send(
|
|
3195
|
+
JSON.stringify({
|
|
3196
|
+
type: "versionChangeProgress",
|
|
3197
|
+
requestId,
|
|
3198
|
+
message: "Removing old containers...",
|
|
3199
|
+
progress: 50,
|
|
3200
|
+
})
|
|
3201
|
+
);
|
|
3202
|
+
|
|
3203
|
+
// Remove containers
|
|
3204
|
+
for (const c of appContainers) {
|
|
3205
|
+
try {
|
|
3206
|
+
await docker.getContainer(c.Id).remove({ force: true });
|
|
3207
|
+
} catch (err) {
|
|
3208
|
+
console.warn("Error removing container", c.Id, err.message);
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// Prepare YAML for the target version and run
|
|
3213
|
+
const yamlContent = convertNodesToYAML(targetApp);
|
|
3214
|
+
|
|
3215
|
+
ws.send(
|
|
3216
|
+
JSON.stringify({
|
|
3217
|
+
type: "versionChangeProgress",
|
|
3218
|
+
requestId,
|
|
3219
|
+
message: "Creating containers for new version...",
|
|
3220
|
+
progress: 70,
|
|
3221
|
+
})
|
|
3222
|
+
);
|
|
3223
|
+
|
|
3224
|
+
// Reuse runBackstageApp flow to create docker-compose and run
|
|
3225
|
+
await runBackstageApp(ws, targetApp, yamlContent, requestId);
|
|
3226
|
+
|
|
3227
|
+
console.log(
|
|
3228
|
+
`✅ Application "${targetApp.name} (${targetVersion})" version changed successfully!`
|
|
3229
|
+
);
|
|
3230
|
+
|
|
3231
|
+
ws.send(
|
|
3232
|
+
JSON.stringify({
|
|
3233
|
+
type: "versionChangeCompleted",
|
|
3234
|
+
requestId,
|
|
3235
|
+
app: { ...targetApp, status: "running" },
|
|
3236
|
+
message: `Successfully changed to version ${targetVersion}`,
|
|
3237
|
+
progress: 100,
|
|
3238
|
+
})
|
|
3239
|
+
);
|
|
3240
|
+
} catch (error) {
|
|
3241
|
+
console.error(
|
|
3242
|
+
chalk.red("❌ Version change failed:", error.message || error)
|
|
3243
|
+
);
|
|
3244
|
+
ws.send(
|
|
3245
|
+
JSON.stringify({
|
|
3246
|
+
type: "versionChangeFailed",
|
|
3247
|
+
requestId,
|
|
3248
|
+
error: error.message || String(error),
|
|
3249
|
+
})
|
|
3250
|
+
);
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
export default { handleAppAction, checkAppHasBeenRun };
|
|
3255
|
+
|
|
3256
|
+
export { handleAppAction, checkAppHasBeenRun };
|