import { DockgeServer } from "./dockge-server"; import fs from "fs"; import { log } from "./log"; import yaml from "yaml"; import { DockgeSocket, ValidationError } from "./util-server"; import path from "path"; import { COMBINED_TERMINAL_COLS, COMBINED_TERMINAL_ROWS, CREATED_FILE, CREATED_STACK, EXITED, getCombinedTerminalName, getComposeTerminalName, getContainerExecTerminalName, PROGRESS_TERMINAL_ROWS, RUNNING, TERMINAL_ROWS, UNKNOWN } from "./util-common"; import { InteractiveTerminal, Terminal } from "./terminal"; import childProcess from "child_process"; export class Stack { name: string; protected _status: number = UNKNOWN; protected _composeYAML?: string; protected _configFilePath?: string; protected _composeFileName: string = "compose.yaml"; protected server: DockgeServer; protected combinedTerminal? : Terminal; protected static managedStackList: Map = new Map(); constructor(server : DockgeServer, name : string, composeYAML? : string, skipFSOperations = false) { this.name = name; this.server = server; this._composeYAML = composeYAML; if (!skipFSOperations) { // Check if compose file name is different from compose.yaml const supportedFileNames = [ "compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml" ]; for (const filename of supportedFileNames) { if (fs.existsSync(path.join(this.path, filename))) { this._composeFileName = filename; break; } } } } toJSON() : object { let obj = this.toSimpleJSON(); return { ...obj, composeYAML: this.composeYAML, }; } toSimpleJSON() : object { return { name: this.name, status: this._status, tags: [], isManagedByDockge: this.isManagedByDockge, composeFileName: this._composeFileName, }; } /** * Get the status of the stack from `docker compose ps --format json` */ ps() : object { let res = childProcess.execSync("docker compose ps --format json", { cwd: this.path }); return JSON.parse(res.toString()); } get isManagedByDockge() : boolean { return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); } get status() : number { return this._status; } validate() { // Check name, allows [a-z][0-9] _ - only if (!this.name.match(/^[a-z0-9_-]+$/)) { throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only"); } // Check YAML format yaml.parse(this.composeYAML); } get composeYAML() : string { if (this._composeYAML === undefined) { try { this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8"); } catch (e) { this._composeYAML = ""; } } return this._composeYAML; } get path() : string { return path.join(this.server.stacksDir, this.name); } get fullPath() : string { let dir = this.path; // Compose up via node-pty let fullPathDir; // if dir is relative, make it absolute if (!path.isAbsolute(dir)) { fullPathDir = path.join(process.cwd(), dir); } else { fullPathDir = dir; } return fullPathDir; } /** * Save the stack to the disk * @param isAdd */ save(isAdd : boolean) { this.validate(); let dir = this.path; // Check if the name is used if isAdd if (isAdd) { if (fs.existsSync(dir)) { throw new ValidationError("Stack name already exists"); } // Create the stack folder fs.mkdirSync(dir); } else { if (!fs.existsSync(dir)) { throw new ValidationError("Stack not found"); } } // Write or overwrite the compose.yaml fs.writeFileSync(path.join(dir, this._composeFileName), this.composeYAML); } async deploy(socket? : DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to deploy, please check the terminal output for more information."); } return exitCode; } async delete(socket?: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to delete, please check the terminal output for more information."); } // Remove the stack folder fs.rmSync(this.path, { recursive: true, force: true }); return exitCode; } updateStatus() { let statusList = Stack.getStatusList(); let status = statusList.get(this.name); if (status) { this._status = status; } else { this._status = UNKNOWN; } } static getStackList(server : DockgeServer, useCacheForManaged = false) : Map { let stacksDir = server.stacksDir; let stackList : Map; if (useCacheForManaged && this.managedStackList.size > 0) { stackList = this.managedStackList; } else { stackList = new Map(); // Scan the stacks directory, and get the stack list let filenameList = fs.readdirSync(stacksDir); for (let filename of filenameList) { try { // Check if it is a directory let stat = fs.statSync(path.join(stacksDir, filename)); if (!stat.isDirectory()) { continue; } let stack = this.getStack(server, filename); stack._status = CREATED_FILE; stackList.set(filename, stack); } catch (e) { if (e instanceof Error) { log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`); } } } // Cache by copying this.managedStackList = new Map(stackList); } // Also get the list from `docker compose ls --all --format json` let res = childProcess.execSync("docker compose ls --all --format json"); let composeList = JSON.parse(res.toString()); for (let composeStack of composeList) { // Skip the dockge stack // TODO: Could be self managed? if (composeStack.Name === "dockge") { continue; } let stack = stackList.get(composeStack.Name); // This stack probably is not managed by Dockge, but we still want to show it if (!stack) { stack = new Stack(server, composeStack.Name); stackList.set(composeStack.Name, stack); } stack._status = this.statusConvert(composeStack.Status); stack._configFilePath = composeStack.ConfigFiles; } return stackList; } /** * Get the status list, it will be used to update the status of the stacks * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned */ static getStatusList() : Map { let statusList = new Map(); let res = childProcess.execSync("docker compose ls --all --format json"); let composeList = JSON.parse(res.toString()); for (let composeStack of composeList) { statusList.set(composeStack.Name, this.statusConvert(composeStack.Status)); } return statusList; } /** * Convert the status string from `docker compose ls` to the status number * Input Example: "exited(1), running(1)" * @param status */ static statusConvert(status : string) : number { if (status.startsWith("created")) { return CREATED_STACK; } else if (status.includes("exited")) { // If one of the service is exited, we consider the stack is exited return EXITED; } else if (status.startsWith("running")) { // If there is no exited services, there should be only running services return RUNNING; } else { return UNKNOWN; } } static getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Stack { let dir = path.join(server.stacksDir, stackName); if (!skipFSOperations) { if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { // Maybe it is a stack managed by docker compose directly let stackList = this.getStackList(server, true); let stack = stackList.get(stackName); if (stack) { return stack; } else { // Really not found throw new ValidationError("Stack not found"); } } } else { log.debug("getStack", "Skip FS operations"); } let stack : Stack; if (!skipFSOperations) { stack = new Stack(server, stackName); } else { stack = new Stack(server, stackName, undefined, true); } stack._status = UNKNOWN; stack._configFilePath = path.resolve(dir); return stack; } async start(socket: DockgeSocket) { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to start, please check the terminal output for more information."); } return exitCode; } async stop(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path); if (exitCode !== 0) { throw new Error("Failed to stop, please check the terminal output for more information."); } return exitCode; } async restart(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); } return exitCode; } async down(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path); if (exitCode !== 0) { throw new Error("Failed to down, please check the terminal output for more information."); } return exitCode; } async update(socket: DockgeSocket) { const terminalName = getComposeTerminalName(this.name); let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path); if (exitCode !== 0) { throw new Error("Failed to pull, please check the terminal output for more information."); } // If the stack is not running, we don't need to restart it this.updateStatus(); log.debug("update", "Status: " + this.status); if (this.status !== RUNNING) { return exitCode; } exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); } return exitCode; } async joinCombinedTerminal(socket: DockgeSocket) { const terminalName = getCombinedTerminalName(this.name); const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path); terminal.rows = COMBINED_TERMINAL_ROWS; terminal.cols = COMBINED_TERMINAL_COLS; terminal.join(socket); terminal.start(); } async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) { const terminalName = getContainerExecTerminalName(this.name, serviceName, index); let terminal = Terminal.getTerminal(terminalName); if (!terminal) { terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path); terminal.rows = TERMINAL_ROWS; log.debug("joinContainerTerminal", "Terminal created"); } terminal.join(socket); terminal.start(); } async getServiceStatusList() { let statusList = new Map(); let res = childProcess.spawnSync("docker", [ "compose", "ps", "--format", "json" ], { cwd: this.path, }); let lines = res.stdout.toString().split("\n"); for (let line of lines) { try { let obj = JSON.parse(line); if (obj.Health === "") { statusList.set(obj.Service, obj.State); } else { statusList.set(obj.Service, obj.Health); } } catch (e) { } } return statusList; } }