import { DockgeServer } from "./dockge-server"; import fs, { promises as fsAsync } from "fs"; import { log } from "./log"; import yaml from "yaml"; import { DockgeSocket, fileExists, ValidationError } from "./util-server"; import path from "path"; import { acceptedComposeFileNames, COMBINED_TERMINAL_COLS, COMBINED_TERMINAL_ROWS, CREATED_FILE, CREATED_STACK, EXITED, getCombinedTerminalName, getComposeTerminalName, getContainerExecTerminalName, PROGRESS_TERMINAL_ROWS, RUNNING, TERMINAL_ROWS, UNKNOWN, } from "../common/util-common"; import { InteractiveTerminal, Terminal } from "./terminal"; import childProcessAsync from "promisify-child-process"; import { Settings } from "./settings"; import { execSync } from "child_process"; import ini from "ini"; export class Stack { name: string; protected _status: number = UNKNOWN; protected _composeYAML?: string; protected _composeENV?: 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, composeENV?: string, skipFSOperations = false ) { this.name = name; this.server = server; this._composeYAML = composeYAML; this._composeENV = composeENV; if (!skipFSOperations) { // Check if compose file name is different from compose.yaml for (const filename of acceptedComposeFileNames) { if (fs.existsSync(path.join(this.path, filename))) { this._composeFileName = filename; break; } } } } async toJSON(endpoint: string): Promise { // Since we have multiple agents now, embed primary hostname in the stack object too. let primaryHostname = await Settings.get("primaryHostname"); if (!primaryHostname) { if (!endpoint) { primaryHostname = "localhost"; } else { // Use the endpoint as the primary hostname try { primaryHostname = new URL("https://" + endpoint).hostname; } catch (e) { // Just in case if the endpoint is in a incorrect format primaryHostname = "localhost"; } } } let obj = this.toSimpleJSON(endpoint); return { ...obj, composeYAML: this.composeYAML, composeENV: this.composeENV, primaryHostname, }; } toSimpleJSON(endpoint: string): object { return { name: this.name, status: this._status, tags: [], isManagedByDockge: this.isManagedByDockge, isGitRepo: this.isGitRepo, gitUrl: this.gitUrl, branch: this.branch, webhook: this.webhook, composeFileName: this._composeFileName, endpoint, }; } /** * Get the status of the stack from `docker compose ps --format json` */ async ps(): Promise { let res = await childProcessAsync.spawn( "docker", ["compose", "ps", "--format", "json"], { cwd: this.path, encoding: "utf-8", } ); if (!res.stdout) { return {}; } return JSON.parse(res.stdout.toString()); } get isManagedByDockge(): boolean { return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); } get isGitRepo(): boolean { try { execSync("git rev-parse --is-inside-work-tree", { cwd: this.path }); return true; } catch (error) { return false; } } get gitUrl() : string { if (this.isGitRepo) { try { let stdout = execSync("git config --get remote.origin.url", { cwd: this.path }); return stdout.toString().trim(); } catch (error) { return ""; } } return ""; } get branch() : string { if (this.isGitRepo) { try { let stdout = execSync("git branch --show-current", { cwd: this.path }); return stdout.toString().trim(); } catch (error) { return ""; } } return ""; } get webhook() : string { //TODO: refine this. if (this.server.config.hostname) { return `http://${this.server.config.hostname}:${this.server.config.port}/webhook/update/${encodeURIComponent(this.name)}`; } else { return `http://localhost:${this.server.config.port}/webhook/update/${encodeURIComponent(this.name)}`; } } 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); let lines = this.composeENV.split("\n"); // Check if the .env is able to pass docker-compose // Prevent "setenv: The parameter is incorrect" // It only happens when there is one line and it doesn't contain "=" if ( lines.length === 1 && !lines[0].includes("=") && lines[0].length > 0 ) { throw new ValidationError("Invalid .env format"); } } 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 composeENV(): string { if (this._composeENV === undefined) { try { this._composeENV = fs.readFileSync( path.join(this.path, ".env"), "utf-8" ); } catch (e) { this._composeENV = ""; } } return this._composeENV; } 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 */ async save(isAdd: boolean) { this.validate(); let dir = this.path; // Check if the name is used if isAdd if (isAdd) { if (await fileExists(dir)) { throw new ValidationError("Stack name already exists"); } // Create the stack folder await fsAsync.mkdir(dir); } else { if (!(await fileExists(dir))) { throw new ValidationError("Stack not found"); } } // Write or overwrite the compose.yaml await fsAsync.writeFile( path.join(dir, this._composeFileName), this.composeYAML ); const envPath = path.join(dir, ".env"); // Write or overwrite the .env // If .env is not existing and the composeENV is empty, we don't need to write it if ((await fileExists(envPath)) || this.composeENV.trim() !== "") { await fsAsync.writeFile(envPath, this.composeENV); } } async deploy(socket: DockgeSocket): Promise { const terminalName = getComposeTerminalName(socket.endpoint, 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(socket.endpoint, 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 await fsAsync.rm(this.path, { recursive: true, force: true, }); return exitCode; } async updateStatus() { let statusList = await Stack.getStatusList(); let status = statusList.get(this.name); if (status) { this._status = status; } else { this._status = UNKNOWN; } } /** * Checks if a compose file exists in the specified directory. * @async * @static * @param {string} dir - The directory to search. * @returns {Promise} A promise that resolves to a boolean indicating whether any compose file exists. */ static async composeFileExists(dir: string): Promise { // Check if any compose file exists for (const filename of acceptedComposeFileNames) { let composeFile = path.join(dir, filename); if (await fileExists(composeFile)) { return true; } } return false; } static async getStackList( server: DockgeServer, useCacheForManaged = false, depth = 0, dir = "" ): Promise> { let stacksDir = server.stacksDir; let currentDir = dir ? path.join(stacksDir, dir) : stacksDir; let stackList: Map; // Use cached stack list? if (useCacheForManaged && this.managedStackList.size > 0) { stackList = this.managedStackList; } else { stackList = new Map(); // Scan the stacks directory, and get the stack list let filenameList = await fsAsync.readdir(currentDir); for (let filename of filenameList) { try { // Check if it is a directory let stat = await fsAsync.stat( path.join(currentDir, filename) ); if (!stat.isDirectory()) { continue; } // If no compose file exists, skip it if ( !(await Stack.composeFileExists( path.join(currentDir, filename) )) ) { if (depth >= 3) { continue; } else { let subStackList = await this.getStackList( server, useCacheForManaged, depth + 1, path.join(dir, filename) ); for (let [subFilename, subStack] of subStackList) { stackList.set(subFilename, subStack); } continue; } } let stack = await this.getStack( server, path.join(dir, filename) ); stack._status = CREATED_FILE; stackList.set(path.join(dir, 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); } // Get status from docker compose ls let res = await childProcessAsync.spawn( "docker", ["compose", "ls", "--all", "--format", "json"], { encoding: "utf-8", } ); if (!res.stdout) { return stackList; } let composeList = JSON.parse(res.stdout.toString()); for (let composeStack of composeList) { let stackName = composeStack.Name; // Adjust the stack name based on the config file path // Get the first config file path let configFile = composeStack.ConfigFiles.split(",")[0]; // Just use the stack name if the config file path is not in the stacks directory if (configFile.trim().startsWith(server.stacksDir) && acceptedComposeFileNames.some(file => configFile.trim().endsWith(file))) { const relativePath = path.relative(server.stacksDir, configFile.trim()); const match = relativePath.match(new RegExp("^(.+)/(.+)\\.ya?ml$")); if (match) { stackName = match[1]; } } let stack = stackList.get(stackName); // This stack probably is not managed by Dockge, but we still want to show it if (!stack) { // Skip the dockge stack if it is not managed by Dockge if (composeStack.Name === "dockge") { continue; } stack = new Stack(server, stackName); stackList.set(stackName, 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 async getStatusList(): Promise> { let statusList = new Map(); let res = await childProcessAsync.spawn( "docker", ["compose", "ls", "--all", "--format", "json"], { encoding: "utf-8", } ); if (!res.stdout) { return statusList; } let composeList = JSON.parse(res.stdout.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 async getStack( server: DockgeServer, stackName: string, skipFSOperations = false ): Promise { let dir = path.join(server.stacksDir, stackName); if (!skipFSOperations) { if ( !(await fileExists(dir)) || !(await fsAsync.stat(dir)).isDirectory() ) { // Maybe it is a stack managed by docker compose directly let stackList = await 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, undefined, true); } stack._status = UNKNOWN; stack._configFilePath = path.resolve(dir); return stack; } async start(socket: DockgeSocket) { const terminalName = getComposeTerminalName(socket.endpoint, 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(socket.endpoint, 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(socket.endpoint, 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(socket.endpoint, 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(socket.endpoint, this.name); if (this.isGitRepo) { // TODO: error handling e.g. local changes let exitCode = await Terminal.exec( this.server, socket, terminalName, "git", ["pull"], this.path ); if (exitCode !== 0) { throw new Error( "Failed to update, please check the terminal output for more information." ); } } 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 await 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 gitSync(socket?: DockgeSocket) { const terminalName = socket ? getComposeTerminalName(socket.endpoint, this.name) : ""; if (!this.isGitRepo) { throw new Error("This stack is not a git repository"); } let exitCode = await Terminal.exec(this.server, socket, terminalName, "git", [ "pull", "--strategy-option", "theirs" ], this.path); if (exitCode !== 0) { throw new Error("Failed to sync, please check the terminal output for more information."); } // If the stack is not running, we don't need to restart it await 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; } checkRemoteChanges() { return new Promise((resolve, reject) => { if (!this.isGitRepo) { reject("This stack is not a git repository"); return; } //fetch remote changes and check if the current branch is behind try { const stdout = execSync("git fetch origin && git status -uno", { cwd: this.path }).toString(); if (stdout.includes("Your branch is behind")) { resolve(true); } else { resolve(false); } } catch (error) { log.error("checkRemoteChanges", error); reject("Failed to check local status"); return; } }); } async joinCombinedTerminal(socket: DockgeSocket) { const terminalName = getCombinedTerminalName( socket.endpoint, this.name ); const terminal = Terminal.getOrCreateTerminal( this.server, terminalName, "docker", ["compose", "logs", "-f", "--tail", "100"], this.path ); terminal.enableKeepAlive = true; terminal.rows = COMBINED_TERMINAL_ROWS; terminal.cols = COMBINED_TERMINAL_COLS; terminal.join(socket); terminal.start(); } async leaveCombinedTerminal(socket: DockgeSocket) { const terminalName = getCombinedTerminalName( socket.endpoint, this.name ); const terminal = Terminal.getTerminal(terminalName); if (terminal) { terminal.leave(socket); } } async joinContainerTerminal( socket: DockgeSocket, serviceName: string, shell: string = "sh", index: number = 0 ) { const terminalName = getContainerExecTerminalName( socket.endpoint, 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(); try { let res = await childProcessAsync.spawn( "docker", ["compose", "ps", "--format", "json"], { cwd: this.path, encoding: "utf-8", } ); if (!res.stdout) { return statusList; } 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; } catch (e) { log.error("getServiceStatusList", e); return statusList; } } }