Skip to content

Commit 45e5bc9

Browse files
authored
Merge pull request #3 from tobi1449/support-nested-stacks
Merge PR louislam#687: Support for nested stacks directory
2 parents 1689edd + 6ee427b commit 45e5bc9

File tree

3 files changed

+105
-68
lines changed

3 files changed

+105
-68
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ View Video: https://youtu.be/AWAlOQeNpgU?t=48
4343
- PR #642: Remove Useless Scrollbar (by https://github.com/cyril59310)
4444
- PR #649: Add Container Control Buttons (by https://github.com/mizady)
4545
- PR #685: Preserve YAML Comments (by https://github.com/turnah)
46+
- PR #687: Support for nested stacks directory (by: https://github.com/mkoo21)
4647
- PR #700: Add Resource Usage Stats (by https://github.com/justwiebe)
4748
- PR #714: Conditional stack files deletion (by: https://github.com/husa)
4849

backend/stack.ts

Lines changed: 93 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { DockgeSocket, fileExists, ValidationError } from "./util-server";
66
import path from "path";
77
import {
88
acceptedComposeFileNames,
9+
acceptedComposeFileNamePattern,
10+
ArbitrarilyNestedLooseObject,
911
COMBINED_TERMINAL_COLS,
1012
COMBINED_TERMINAL_ROWS,
1113
CREATED_FILE,
@@ -108,7 +110,7 @@ export class Stack {
108110
}
109111

110112
get isManagedByDockge() : boolean {
111-
return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
113+
return !!this._configFilePath && this._configFilePath.startsWith(this.server.stacksDir);
112114
}
113115

114116
get status() : number {
@@ -157,7 +159,7 @@ export class Stack {
157159
}
158160

159161
get path() : string {
160-
return path.join(this.server.stacksDir, this.name);
162+
return this._configFilePath || "";
161163
}
162164

163165
get fullPath() : string {
@@ -267,41 +269,12 @@ export class Stack {
267269
}
268270

269271
static async getStackList(server : DockgeServer, useCacheForManaged = false) : Promise<Map<string, Stack>> {
270-
let stacksDir = server.stacksDir;
271-
let stackList : Map<string, Stack>;
272+
let stackList : Map<string, Stack> = new Map<string, Stack>();
272273

273274
// Use cached stack list?
274275
if (useCacheForManaged && this.managedStackList.size > 0) {
275276
stackList = this.managedStackList;
276-
} else {
277-
stackList = new Map<string, Stack>();
278-
279-
// Scan the stacks directory, and get the stack list
280-
let filenameList = await fsAsync.readdir(stacksDir);
281-
282-
for (let filename of filenameList) {
283-
try {
284-
// Check if it is a directory
285-
let stat = await fsAsync.stat(path.join(stacksDir, filename));
286-
if (!stat.isDirectory()) {
287-
continue;
288-
}
289-
// If no compose file exists, skip it
290-
if (!await Stack.composeFileExists(stacksDir, filename)) {
291-
continue;
292-
}
293-
let stack = await this.getStack(server, filename);
294-
stack._status = CREATED_FILE;
295-
stackList.set(filename, stack);
296-
} catch (e) {
297-
if (e instanceof Error) {
298-
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
299-
}
300-
}
301-
}
302-
303-
// Cache by copying
304-
this.managedStackList = new Map(stackList);
277+
return stackList;
305278
}
306279

307280
// Get status from docker compose ls
@@ -310,28 +283,92 @@ export class Stack {
310283
});
311284

312285
if (!res.stdout) {
286+
log.warn("getStackList", "No response from docker compose daemon when attempting to retrieve list of stacks");
313287
return stackList;
314288
}
315289

316290
let composeList = JSON.parse(res.stdout.toString());
291+
let pathSearchTree: ArbitrarilyNestedLooseObject = {}; // search structure for matching paths
317292

318293
for (let composeStack of composeList) {
319-
let stack = stackList.get(composeStack.Name);
320-
321-
// This stack probably is not managed by Dockge, but we still want to show it
322-
if (!stack) {
323-
// Skip the dockge stack if it is not managed by Dockge
324-
if (composeStack.Name === "dockge") {
294+
try {
295+
let stack = new Stack(server, composeStack.Name);
296+
stack._status = this.statusConvert(composeStack.Status);
297+
298+
let composeFiles = composeStack.ConfigFiles.split(","); // it is possible for a project to have more than one config file
299+
stack._configFilePath = path.dirname(composeFiles[0]);
300+
stack._composeFileName = path.basename(composeFiles[0]);
301+
if (stack.name === "dockge" && !stack.isManagedByDockge) {
302+
// skip dockge if not managed by dockge
325303
continue;
326304
}
327-
stack = new Stack(server, composeStack.Name);
328305
stackList.set(composeStack.Name, stack);
306+
307+
// add project path to search tree so we can quickly decide if we have seen it before later
308+
// e.g. path "/opt/stacks" would yield the tree { opt: stacks: {} }
309+
path.join(stack._configFilePath, stack._composeFileName).split(path.sep).reduce((searchTree, pathComponent) => {
310+
if (pathComponent == "") {
311+
return searchTree;
312+
}
313+
if (!searchTree[pathComponent]) {
314+
searchTree[pathComponent] = {};
315+
}
316+
return searchTree[pathComponent];
317+
}, pathSearchTree);
318+
} catch (e) {
319+
if (e instanceof Error) {
320+
log.error("getStackList", `Failed to get stack ${composeStack.Name}, error: ${e.message}`);
321+
}
329322
}
323+
}
324+
325+
// Search stacks directory for compose files not associated with a running compose project (ie. never started through CLI)
326+
try {
327+
// Hopefully the user has access to everything in this directory! If they don't, log the error. It is a small price to pay for fast searching.
328+
let rawFilesList = fs.readdirSync(server.stacksDir, {
329+
recursive: true,
330+
withFileTypes: true
331+
});
332+
let acceptedComposeFiles = rawFilesList.filter((dirEnt: fs.Dirent) => dirEnt.isFile() && !!dirEnt.name.match(acceptedComposeFileNamePattern));
333+
log.debug("getStackList", `Folder scan yielded ${acceptedComposeFiles.length} files`);
334+
for (let composeFile of acceptedComposeFiles) {
335+
// check if we have seen this file before
336+
let fullPath = composeFile.parentPath;
337+
let previouslySeen = fullPath.split(path.sep).reduce((searchTree: ArbitrarilyNestedLooseObject | boolean, pathComponent) => {
338+
if (pathComponent == "") {
339+
return searchTree;
340+
}
330341

331-
stack._status = this.statusConvert(composeStack.Status);
332-
stack._configFilePath = composeStack.ConfigFiles;
342+
// end condition
343+
if (searchTree == false || !(searchTree as ArbitrarilyNestedLooseObject)[pathComponent]) {
344+
return false;
345+
}
346+
347+
// path (so far) has been previously seen
348+
return (searchTree as ArbitrarilyNestedLooseObject)[pathComponent];
349+
}, pathSearchTree);
350+
if (!previouslySeen) {
351+
// a file with an accepted compose filename has been found that did not appear in `docker compose ls`. Use its config file path as a temp name
352+
log.info("getStackList", `Found project unknown to docker compose: ${fullPath}/${composeFile.name}`);
353+
let [ configFilePath, configFilename, inferredProjectName ] = [ fullPath, composeFile.name, path.basename(fullPath) ];
354+
if (stackList.get(inferredProjectName)) {
355+
log.info("getStackList", `... but it was ignored. A project named ${inferredProjectName} already exists`);
356+
} else {
357+
let stack = new Stack(server, inferredProjectName);
358+
stack._status = UNKNOWN;
359+
stack._configFilePath = configFilePath;
360+
stack._composeFileName = configFilename;
361+
stackList.set(inferredProjectName, stack);
362+
}
363+
}
364+
}
365+
} catch (e) {
366+
if (e instanceof Error) {
367+
log.error("getStackList", `Got error searching for undiscovered stacks:\n${e.message}`);
368+
}
333369
}
334370

371+
this.managedStackList = stackList;
335372
return stackList;
336373
}
337374

@@ -379,35 +416,24 @@ export class Stack {
379416
}
380417

381418
static async getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Promise<Stack> {
382-
let dir = path.join(server.stacksDir, stackName);
383-
419+
let stack: Stack | undefined;
384420
if (!skipFSOperations) {
385-
if (!await fileExists(dir) || !(await fsAsync.stat(dir)).isDirectory()) {
386-
// Maybe it is a stack managed by docker compose directly
387-
let stackList = await this.getStackList(server, true);
388-
let stack = stackList.get(stackName);
389-
390-
if (stack) {
391-
return stack;
392-
} else {
393-
// Really not found
394-
throw new ValidationError("Stack not found");
395-
}
421+
let stackList = await this.getStackList(server, true);
422+
stack = stackList.get(stackName);
423+
if (!stack || !await fileExists(stack.path) || !(await fsAsync.stat(stack.path)).isDirectory() ) {
424+
throw new ValidationError(`getStack; Stack ${stackName} not found`);
396425
}
397426
} else {
398-
//log.debug("getStack", "Skip FS operations");
399-
}
400-
401-
let stack : Stack;
402-
403-
if (!skipFSOperations) {
404-
stack = new Stack(server, stackName);
405-
} else {
406-
stack = new Stack(server, stackName, undefined, undefined, true);
427+
// search for known stack with this name
428+
if (this.managedStackList) {
429+
stack = this.managedStackList.get(stackName);
430+
}
431+
if (!this.managedStackList || !stack) {
432+
stack = new Stack(server, stackName, undefined, undefined, true);
433+
stack._status = UNKNOWN;
434+
stack._configFilePath = path.resolve(server.stacksDir, stackName);
435+
}
407436
}
408-
409-
stack._status = UNKNOWN;
410-
stack._configFilePath = path.resolve(dir);
411437
return stack;
412438
}
413439

@@ -536,7 +562,6 @@ export class Stack {
536562
} catch (e) {
537563
}
538564
}
539-
540565
return statusList;
541566
} catch (e) {
542567
log.error("getServiceStatusList", e);

common/util-common.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface LooseObject {
2121
[key: string]: any
2222
}
2323

24+
export interface ArbitrarilyNestedLooseObject {
25+
[key: string]: ArbitrarilyNestedLooseObject | Record<string, never>;
26+
}
27+
2428
export interface BaseRes {
2529
ok: boolean;
2630
msg?: string;
@@ -125,6 +129,13 @@ export const acceptedComposeFileNames = [
125129
"compose.yml",
126130
];
127131

132+
// Make a regex out of accepted compose file names
133+
export const acceptedComposeFileNamePattern = new RegExp(
134+
acceptedComposeFileNames
135+
.map((filename: string) => filename.replace(".", "\\$&"))
136+
.join("|")
137+
);
138+
128139
/**
129140
* Generate a decimal integer number from a string
130141
* @param str Input

0 commit comments

Comments
 (0)