diff --git a/.dockerignore b/.dockerignore index 1d95d8cf..5e5f1725 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ data stacks tmp /private +.pnpm-store # Docker extra docker diff --git a/.gitignore b/.gitignore index decd8171..17d3387f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ data stacks tmp /private +.pnpm-store # Git only frontend-dist diff --git a/README.md b/README.md index 7daf6bd2..01bb6e7c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ View Video: https://youtu.be/AWAlOQeNpgU?t=48 - PR #685: Preserve YAML Comments (by https://github.com/turnah) - PR #700: Add Resource Usage Stats (by https://github.com/justwiebe) - PR #714: Conditional stack files deletion (by: https://github.com/husa) +- PR #687: Support for nested stacks directory (by: https://github.com/mkoo21) ## 🔧 How to Install diff --git a/backend/agent-socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts index 0b3e1546..89d3ad8b 100644 --- a/backend/agent-socket-handlers/docker-socket-handler.ts +++ b/backend/agent-socket-handlers/docker-socket-handler.ts @@ -3,6 +3,8 @@ import { DockgeServer } from "../dockge-server"; import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server"; import { DeleteOptions, Stack } from "../stack"; import { AgentSocket } from "../../common/agent-socket"; +import { Terminal } from "../terminal"; +import { getComposeTerminalName } from "../../common/util-common"; export class DockerSocketHandler extends AgentSocketHandler { create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) { @@ -25,6 +27,47 @@ export class DockerSocketHandler extends AgentSocketHandler { } }); + agentSocket.on("gitDeployStack", async (stackName : unknown, gitUrl : unknown, branch : unknown, isAdd : unknown, callback) => { + try { + checkLogin(socket); + + if (typeof(stackName) !== "string") { + throw new ValidationError("Stack name must be a string"); + } + if (typeof(gitUrl) !== "string") { + throw new ValidationError("Git URL must be a string"); + } + if (typeof(branch) !== "string") { + throw new ValidationError("Git Ref must be a string"); + } + + const terminalName = getComposeTerminalName(socket.endpoint, stackName); + + // TODO: this could be done smarter. + if (!isAdd) { + const stack = await Stack.getStack(server, stackName); + await stack.delete(socket); + } + + let exitCode = await Terminal.exec(server, socket, terminalName, "git", [ "clone", "-b", branch, gitUrl, stackName ], server.stacksDir); + if (exitCode !== 0) { + throw new Error(`Failed to clone git repo [Exit Code ${exitCode}]`); + } + + const stack = await Stack.getStack(server, stackName); + await stack.deploy(socket); + + server.sendStackList(); + callbackResult({ + ok: true, + msg: "Deployed" + }, callback); + stack.joinCombinedTerminal(socket); + } catch (e) { + callbackError(e, callback); + } + }); + agentSocket.on("saveStack", async (name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown, callback) => { try { checkLogin(socket); @@ -197,6 +240,27 @@ export class DockerSocketHandler extends AgentSocketHandler { } }); + // gitSync + agentSocket.on("gitSync", async (stackName : unknown, callback) => { + try { + checkLogin(socket); + + if (typeof(stackName) !== "string") { + throw new ValidationError("Stack name must be a string"); + } + + const stack = await Stack.getStack(server, stackName); + await stack.gitSync(socket); + callbackResult({ + ok: true, + msg: "Synced" + }, callback); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + // down stack agentSocket.on("downStack", async (stackName : unknown, callback) => { try { diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index ecc6aa7d..c4700b03 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import { MainRouter } from "./routers/main-router"; +import { WebhookRouter } from "./routers/webhook-router"; import * as fs from "node:fs"; import { PackageJson } from "type-fest"; import { Database } from "./database"; @@ -21,7 +22,7 @@ import { R } from "redbean-node"; import { genSecret, isDev, LooseObject } from "../common/util-common"; import { generatePasswordHash } from "./password-hash"; import { Bean } from "redbean-node/dist/bean"; -import { Arguments, Config, DockgeSocket } from "./util-server"; +import { Arguments, Config, DockgeSocket, ValidationError } from "./util-server"; import { DockerSocketHandler } from "./agent-socket-handlers/docker-socket-handler"; import expressStaticGzip from "express-static-gzip"; import path from "path"; @@ -38,6 +39,8 @@ import { AgentSocket } from "../common/agent-socket"; import { ManageAgentSocketHandler } from "./socket-handlers/manage-agent-socket-handler"; import { Terminal } from "./terminal"; +const GIT_UPDATE_CHECKER_INTERVAL_MS = 1000 * 60 * 10; + export class DockgeServer { app : Express; httpServer : http.Server; @@ -45,12 +48,14 @@ export class DockgeServer { io : socketIO.Server; config : Config; indexHTML : string = ""; + gitUpdateInterval : NodeJS.Timeout | undefined; /** * List of express routers */ routerList : Router[] = [ new MainRouter(), + new WebhookRouter(), ]; /** @@ -204,6 +209,17 @@ export class DockgeServer { }; } + // add a middleware to handle errors + this.app.use((err : unknown, _req : express.Request, res : express.Response, _next : express.NextFunction) => { + if (err instanceof Error) { + res.status(500).json({ error: err.message }); + } else if (err instanceof ValidationError) { + res.status(400).json({ error: err.message }); + } else { + res.status(500).json({ error: "Unknown error: " + err }); + } + }); + // Create Socket.io this.io = new socketIO.Server(this.httpServer, { cors, @@ -398,6 +414,7 @@ export class DockgeServer { }); checkVersion.startInterval(); + this.startGitUpdater(); }); gracefulShutdown(this.httpServer, { @@ -610,6 +627,47 @@ export class DockgeServer { } } + /** + * Start the git updater. This checks for outdated stacks and updates them. + * @param useCache + */ + async startGitUpdater(useCache = false) { + const check = async () => { + if (await Settings.get("gitAutoUpdate") !== true) { + return; + } + + log.debug("git-updater", "checking for outdated stacks"); + + let socketList = this.io.sockets.sockets.values(); + + let stackList; + for (let socket of socketList) { + let dockgeSocket = socket as DockgeSocket; + + // Get the list of stacks only once + if (!stackList) { + stackList = await Stack.getStackList(this, useCache); + } + + for (let [ stackName, stack ] of stackList) { + + if (stack.isGitRepo) { + stack.checkRemoteChanges().then(async (outdated) => { + if (outdated) { + log.info("git-updater", `Stack ${stackName} is outdated, Updating...`); + await stack.update(dockgeSocket); + } + }); + } + } + } + }; + + await check(); + this.gitUpdateInterval = setInterval(check, GIT_UPDATE_CHECKER_INTERVAL_MS) as NodeJS.Timeout; + } + async getDockerNetworkList() : Promise { let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], { encoding: "utf-8", @@ -673,6 +731,10 @@ export class DockgeServer { log.info("server", "Shutdown requested"); log.info("server", "Called signal: " + signal); + if (this.gitUpdateInterval) { + clearInterval(this.gitUpdateInterval); + } + // TODO: Close all terminals? await Database.close(); diff --git a/backend/routers/webhook-router.ts b/backend/routers/webhook-router.ts new file mode 100644 index 00000000..f86787d9 --- /dev/null +++ b/backend/routers/webhook-router.ts @@ -0,0 +1,34 @@ +import { DockgeServer } from "../dockge-server"; +import { log } from "../log"; +import { Router } from "../router"; +import express, { Express, Router as ExpressRouter } from "express"; +import { Stack } from "../stack"; + +export class WebhookRouter extends Router { + create(app: Express, server: DockgeServer): ExpressRouter { + const router = express.Router(); + + router.get("/webhook/update/:stackname", async (req, res, _next) => { + try { + const stackname = req.params.stackname; + + log.info("router", `Webhook received for stack: ${stackname}`); + const stack = await Stack.getStack(server, stackname); + if (!stack) { + log.error("router", `Stack not found: ${stackname}`); + res.status(404).json({ message: `Stack not found: ${stackname}` }); + return; + } + await stack.gitSync(undefined); + + // Send a response + res.json({ message: `Updated stack: ${stackname}` }); + + } catch (error) { + _next(error); + } + }); + + return router; + } +} diff --git a/backend/socket-handlers/main-socket-handler.ts b/backend/socket-handlers/main-socket-handler.ts index 5d31878a..ab884376 100644 --- a/backend/socket-handlers/main-socket-handler.ts +++ b/backend/socket-handlers/main-socket-handler.ts @@ -18,6 +18,8 @@ import { import { passwordStrength } from "check-password-strength"; import jwt from "jsonwebtoken"; import { Settings } from "../settings"; +import fs, { promises as fsAsync } from "fs"; +import path from "path"; export class MainSocketHandler extends SocketHandler { create(socket : DockgeSocket, server : DockgeServer) { @@ -242,6 +244,12 @@ export class MainSocketHandler extends SocketHandler { checkLogin(socket); const data = await Settings.getSettings("general"); + if (fs.existsSync(path.join(server.stacksDir, "global.env"))) { + data.globalENV = fs.readFileSync(path.join(server.stacksDir, "global.env"), "utf-8"); + } else { + data.globalENV = "# VARIABLE=value #comment"; + } + callback({ ok: true, data: data, @@ -270,6 +278,16 @@ export class MainSocketHandler extends SocketHandler { if (!currentDisabledAuth && data.disableAuth) { await doubleCheckPassword(socket, currentPassword); } + // Handle global.env + if (data.globalENV && data.globalENV != "# VARIABLE=value #comment") { + await fsAsync.writeFile(path.join(server.stacksDir, "global.env"), data.globalENV); + } else { + await fsAsync.rm(path.join(server.stacksDir, "global.env"), { + recursive: true, + force: true + }); + } + delete data.globalENV; await Settings.setSettings("general", data); diff --git a/backend/stack.ts b/backend/stack.ts index a04399dc..7f09ec48 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -6,6 +6,8 @@ import { DockgeSocket, fileExists, ValidationError } from "./util-server"; import path from "path"; import { acceptedComposeFileNames, + acceptedComposeFileNamePattern, + ArbitrarilyNestedLooseObject, COMBINED_TERMINAL_COLS, COMBINED_TERMINAL_ROWS, CREATED_FILE, @@ -19,6 +21,8 @@ import { 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 interface DeleteOptions { deleteStackFiles: boolean @@ -82,22 +86,34 @@ export class Stack { }; } - toSimpleJSON(endpoint : string) : object { - return { + toSimpleJSON(endpoint: string): object { + let isGitRepo = this.isGitRepo; + let obj = { name: this.name, status: this._status, tags: [], isManagedByDockge: this.isManagedByDockge, + isGitRepo: isGitRepo, composeFileName: this._composeFileName, endpoint, }; + if (isGitRepo) { + return { + ...obj, + gitUrl: this.gitUrl, + branch: this.branch, + webhook: this.webhook, + }; + } else { + return obj; + } } /** * 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" ], { + let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { cwd: this.path, encoding: "utf-8", }); @@ -108,7 +124,49 @@ export class Stack { } get isManagedByDockge() : boolean { - return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); + return !!this._configFilePath && this._configFilePath.startsWith(this.server.stacksDir); + } + + 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 { @@ -157,7 +215,7 @@ export class Stack { } get path() : string { - return path.join(this.server.stacksDir, this.name); + return this._configFilePath || ""; } get fullPath() : string { @@ -210,7 +268,7 @@ export class Stack { 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); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to deploy, please check the terminal output for more information."); } @@ -219,7 +277,7 @@ export class Stack { async delete(socket: DockgeSocket, options: DeleteOptions) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to delete, please check the terminal output for more information."); } @@ -267,41 +325,12 @@ export class Stack { } static async getStackList(server : DockgeServer, useCacheForManaged = false) : Promise> { - let stacksDir = server.stacksDir; - let stackList : Map; + let stackList : Map = new 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(stacksDir); - - for (let filename of filenameList) { - try { - // Check if it is a directory - let stat = await fsAsync.stat(path.join(stacksDir, filename)); - if (!stat.isDirectory()) { - continue; - } - // If no compose file exists, skip it - if (!await Stack.composeFileExists(stacksDir, filename)) { - continue; - } - let stack = await 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); + return stackList; } // Get status from docker compose ls @@ -310,28 +339,92 @@ export class Stack { }); if (!res.stdout) { + log.warn("getStackList", "No response from docker compose daemon when attempting to retrieve list of stacks"); return stackList; } let composeList = JSON.parse(res.stdout.toString()); + let pathSearchTree: ArbitrarilyNestedLooseObject = {}; // search structure for matching paths for (let composeStack of composeList) { - let stack = stackList.get(composeStack.Name); - - // 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") { + try { + let stack = new Stack(server, composeStack.Name); + stack._status = this.statusConvert(composeStack.Status); + + let composeFiles = composeStack.ConfigFiles.split(","); // it is possible for a project to have more than one config file + stack._configFilePath = path.dirname(composeFiles[0]); + stack._composeFileName = path.basename(composeFiles[0]); + if (stack.name === "dockge" && !stack.isManagedByDockge) { + // skip dockge if not managed by dockge continue; } - stack = new Stack(server, composeStack.Name); stackList.set(composeStack.Name, stack); + + // add project path to search tree so we can quickly decide if we have seen it before later + // e.g. path "/opt/stacks" would yield the tree { opt: stacks: {} } + path.join(stack._configFilePath, stack._composeFileName).split(path.sep).reduce((searchTree, pathComponent) => { + if (pathComponent == "") { + return searchTree; + } + if (!searchTree[pathComponent]) { + searchTree[pathComponent] = {}; + } + return searchTree[pathComponent]; + }, pathSearchTree); + } catch (e) { + if (e instanceof Error) { + log.error("getStackList", `Failed to get stack ${composeStack.Name}, error: ${e.message}`); + } } + } - stack._status = this.statusConvert(composeStack.Status); - stack._configFilePath = composeStack.ConfigFiles; + // Search stacks directory for compose files not associated with a running compose project (ie. never started through CLI) + try { + // 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. + let rawFilesList = fs.readdirSync(server.stacksDir, { + recursive: true, + withFileTypes: true + }); + let acceptedComposeFiles = rawFilesList.filter((dirEnt: fs.Dirent) => dirEnt.isFile() && !!dirEnt.name.match(acceptedComposeFileNamePattern)); + log.debug("getStackList", `Folder scan yielded ${acceptedComposeFiles.length} files`); + for (let composeFile of acceptedComposeFiles) { + // check if we have seen this file before + let fullPath = composeFile.parentPath; + let previouslySeen = fullPath.split(path.sep).reduce((searchTree: ArbitrarilyNestedLooseObject | boolean, pathComponent) => { + if (pathComponent == "") { + return searchTree; + } + + // end condition + if (searchTree == false || !(searchTree as ArbitrarilyNestedLooseObject)[pathComponent]) { + return false; + } + + // path (so far) has been previously seen + return (searchTree as ArbitrarilyNestedLooseObject)[pathComponent]; + }, pathSearchTree); + if (!previouslySeen) { + // 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 + log.info("getStackList", `Found project unknown to docker compose: ${fullPath}/${composeFile.name}`); + let [ configFilePath, configFilename, inferredProjectName ] = [ fullPath, composeFile.name, path.basename(fullPath) ]; + if (stackList.get(inferredProjectName)) { + log.info("getStackList", `... but it was ignored. A project named ${inferredProjectName} already exists`); + } else { + let stack = new Stack(server, inferredProjectName); + stack._status = UNKNOWN; + stack._configFilePath = configFilePath; + stack._composeFileName = configFilename; + stackList.set(inferredProjectName, stack); + } + } + } + } catch (e) { + if (e instanceof Error) { + log.error("getStackList", `Got error searching for undiscovered stacks:\n${e.message}`); + } } + this.managedStackList = stackList; return stackList; } @@ -379,41 +472,43 @@ export class Stack { } static async getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Promise { - let dir = path.join(server.stacksDir, stackName); - + let stack: Stack | undefined; 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"); - } + let stackList = await this.getStackList(server, true); + stack = stackList.get(stackName); + if (!stack || !await fileExists(stack.path) || !(await fsAsync.stat(stack.path)).isDirectory() ) { + throw new ValidationError(`getStack; Stack ${stackName} not found`); } } else { - //log.debug("getStack", "Skip FS operations"); + // search for known stack with this name + if (this.managedStackList) { + stack = this.managedStackList.get(stackName); + } + if (!this.managedStackList || !stack) { + stack = new Stack(server, stackName, undefined, undefined, true); + stack._status = UNKNOWN; + stack._configFilePath = path.resolve(server.stacksDir, stackName); + } } + return stack; + } - let stack : Stack; - - if (!skipFSOperations) { - stack = new Stack(server, stackName); - } else { - stack = new Stack(server, stackName, undefined, undefined, true); + getComposeOptions(command : string, ...extraOptions : string[]) { + //--env-file ./../global.env --env-file .env + let options = [ "compose", command, ...extraOptions ]; + if (fs.existsSync(path.join(this.server.stacksDir, "global.env"))) { + if (fs.existsSync(path.join(this.path, ".env"))) { + options.splice(1, 0, "--env-file", "./.env"); + } + options.splice(1, 0, "--env-file", "../global.env"); } - - stack._status = UNKNOWN; - stack._configFilePath = path.resolve(dir); - return stack; + console.log(options); + return options; } 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); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to start, please check the terminal output for more information."); } @@ -422,7 +517,7 @@ export class Stack { 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); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("stop"), this.path); if (exitCode !== 0) { throw new Error("Failed to stop, please check the terminal output for more information."); } @@ -431,7 +526,7 @@ export class Stack { 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); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("restart"), this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); } @@ -440,7 +535,7 @@ export class Stack { 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); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down"), this.path); if (exitCode !== 0) { throw new Error("Failed to down, please check the terminal output for more information."); } @@ -449,7 +544,16 @@ export class Stack { async update(socket: DockgeSocket) { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull", "--policy", "missing" ], this.path); + + 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", this.getComposeOptions("pull", "--policy", "missing"), this.path); if (exitCode !== 0) { throw new Error("Failed to pull, please check the terminal output for more information."); } @@ -461,16 +565,64 @@ export class Stack { return exitCode; } - exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); + exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("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", this.getComposeOptions("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); + const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", this.getComposeOptions("logs", "-f", "--tail", "100"), this.path); terminal.enableKeepAlive = true; terminal.rows = COMBINED_TERMINAL_ROWS; terminal.cols = COMBINED_TERMINAL_COLS; @@ -491,7 +643,7 @@ export class Stack { let terminal = Terminal.getTerminal(terminalName); if (!terminal) { - terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path); + terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", serviceName, shell), this.path); terminal.rows = TERMINAL_ROWS; log.debug("joinContainerTerminal", "Terminal created"); } @@ -504,7 +656,11 @@ export class Stack { let statusList = new Map>(); try { - let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { + if (!(await fsAsync.stat(this.path)).isDirectory()) { + return statusList; + } + + let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { cwd: this.path, encoding: "utf-8", }); @@ -515,12 +671,15 @@ export class Stack { let lines = res.stdout?.toString().split("\n"); - const addLine = (obj: { Service: string, State: string, Name: string, Health: string }) => { + const addLine = (obj: { Service: string, State: string, Name: string, Health: string, Ports: string }) => { if (!statusList.has(obj.Service)) { statusList.set(obj.Service, []); } statusList.get(obj.Service)?.push({ status: obj.Health || obj.State, + ports: obj.Ports.split(/,\s*/).filter((s) => { + return s.indexOf("->") >= 0; + }), name: obj.Name }); }; @@ -536,7 +695,6 @@ export class Stack { } catch (e) { } } - return statusList; } catch (e) { log.error("getServiceStatusList", e); @@ -546,7 +704,7 @@ export class Stack { async startService(socket: DockgeSocket, serviceName: string) { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", ["compose", "up", "-d", serviceName], this.path); + const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", serviceName), this.path); if (exitCode !== 0) { throw new Error(`Failed to start service ${serviceName}, please check logs for more information.`); } @@ -556,7 +714,7 @@ export class Stack { async stopService(socket: DockgeSocket, serviceName: string): Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", ["compose", "stop", serviceName], this.path); + const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("stop", serviceName), this.path); if (exitCode !== 0) { throw new Error(`Failed to stop service ${serviceName}, please check logs for more information.`); } @@ -566,7 +724,7 @@ export class Stack { async restartService(socket: DockgeSocket, serviceName: string): Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", ["compose", "restart", serviceName], this.path); + const exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("restart", serviceName), this.path); if (exitCode !== 0) { throw new Error(`Failed to restart service ${serviceName}, please check logs for more information.`); } diff --git a/common/util-common.ts b/common/util-common.ts index e067c28c..8a27e59b 100644 --- a/common/util-common.ts +++ b/common/util-common.ts @@ -21,6 +21,10 @@ export interface LooseObject { [key: string]: any } +export interface ArbitrarilyNestedLooseObject { + [key: string]: ArbitrarilyNestedLooseObject | Record; +} + export interface BaseRes { ok: boolean; msg?: string; @@ -125,6 +129,13 @@ export const acceptedComposeFileNames = [ "compose.yml", ]; +// Make a regex out of accepted compose file names +export const acceptedComposeFileNamePattern = new RegExp( + acceptedComposeFileNames + .map((filename: string) => filename.replace(".", "\\$&")) + .join("|") +); + /** * Generate a decimal integer number from a string * @param str Input @@ -310,6 +321,7 @@ function copyYAMLCommentsItems(items: any, srcItems: any) { * - "8000-9000:80" * - "127.0.0.1:8001:8001" * - "127.0.0.1:5000-5010:5000-5010" + * - "0.0.0.0:8080->8080/tcp" * - "6060:6060/udp" * @param input * @param hostname @@ -319,9 +331,19 @@ export function parseDockerPort(input : string, hostname : string) { let display; const parts = input.split("/"); - const part1 = parts[0]; + let part1 = parts[0]; let protocol = parts[1] || "tcp"; + // coming from docker ps, split host part + const arrow = part1.indexOf("->"); + if (arrow >= 0) { + part1 = part1.split("->")[0]; + const colon = part1.indexOf(":"); + if (colon >= 0) { + part1 = part1.split(":")[1]; + } + } + // Split the last ":" const lastColon = part1.lastIndexOf(":"); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 5482c88f..38c73d75 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -22,6 +22,8 @@ declare module 'vue' { Container: typeof import('./src/components/Container.vue')['default'] DockerStat: typeof import('./src/components/DockerStat.vue')['default'] General: typeof import('./src/components/settings/General.vue')['default'] + GitOps: typeof import('./src/components/settings/GitOps.vue')['default'] + GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default'] HiddenInput: typeof import('./src/components/HiddenInput.vue')['default'] Login: typeof import('./src/components/Login.vue')['default'] NetworkInput: typeof import('./src/components/NetworkInput.vue')['default'] diff --git a/frontend/src/components/Container.vue b/frontend/src/components/Container.vue index 7c94c7af..221d7c99 100644 --- a/frontend/src/components/Container.vue +++ b/frontend/src/components/Container.vue @@ -9,7 +9,7 @@ @@ -211,6 +211,10 @@ export default defineComponent({ }, dockerStats: { type: Object, + default: null, + }, + ports: { + type: Array, default: null } }, diff --git a/frontend/src/components/StackListItem.vue b/frontend/src/components/StackListItem.vue index 7e7561b2..2ed628ad 100644 --- a/frontend/src/components/StackListItem.vue +++ b/frontend/src/components/StackListItem.vue @@ -5,6 +5,9 @@ {{ stackName }}
{{ endpointDisplay }}
+
+ +
@@ -58,9 +61,9 @@ export default { }, url() { if (this.stack.endpoint) { - return `/compose/${this.stack.name}/${this.stack.endpoint}`; + return `/compose/${encodeURIComponent(this.stack.name)}/${this.stack.endpoint}`; } else { - return `/compose/${this.stack.name}`; + return `/compose/${encodeURIComponent(this.stack.name)}`; } }, depthMargin() { @@ -178,4 +181,8 @@ export default { opacity: 0.5; } +.icon-container { + margin-left: auto; +} + diff --git a/frontend/src/components/settings/GitOps.vue b/frontend/src/components/settings/GitOps.vue new file mode 100644 index 00000000..f0decca2 --- /dev/null +++ b/frontend/src/components/settings/GitOps.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/src/components/settings/GlobalEnv.vue b/frontend/src/components/settings/GlobalEnv.vue new file mode 100644 index 00000000..db09dceb --- /dev/null +++ b/frontend/src/components/settings/GlobalEnv.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/icon.ts b/frontend/src/icon.ts index 6380eac9..c8d96586 100644 --- a/frontend/src/icon.ts +++ b/frontend/src/icon.ts @@ -55,6 +55,8 @@ import { faTerminal, faWarehouse, faHome, faRocket, faRotate, faCloudArrowDown, faArrowsRotate, + faCodeBranch, + faFileCode, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -111,6 +113,8 @@ library.add( faRotate, faCloudArrowDown, faArrowsRotate, + faCodeBranch, + faFileCode, ); export { FontAwesomeIcon }; diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 11d0098b..3ee8bb31 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -114,6 +114,7 @@ "agentRemovedSuccessfully": "Agent removed successfully.", "removeAgent": "Remove Agent", "removeAgentMsg": "Are you sure you want to remove this agent?", +<<<<<<< HEAD "LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor.", "name": "Dockge Agent Display name", "updatedName": "New Dockge Agent Display name", @@ -136,5 +137,19 @@ "memory": "Memory", "memoryAbbreviated": "Mem", "networkIO": "Network I/O", - "blockIO": "Block I/O" + "blockIO": "Block I/O", + "repositoryUrl": "Repository URL", + "branch": "Branch", + "gitAutoUpdate": "[GitOps] Auto Update", + "enableAutoUpdate": "Check periodically for updates", + "ManageWithGit": "Manage this stack with Git", + "webhook": "Webhook URL to trigger update", + "copy": "Copy", + "GitOps": "GitOps", + "GitConfig": "Git Configuration", + "gitSync": "Sync Repo" +======= + "GlobalEnv": "Global .env", + "LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor." +>>>>>>> origin-syko9000/master.global.env } diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index 0b769625..6bee483b 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -16,7 +16,7 @@ {{ $t("deployStack") }} - @@ -41,6 +41,11 @@ {{ $t("updateStack") }} + + - + +
+

{{ $t("GitConfig") }}

+
+ +
+ +
+ +
+ +
+
-
- + +
+ +
+ {{ stack.branch }} + +
+ +
+
+ + +
+ +
+ + +
+
+
- + +
+

{{ $tc("container", 2) }}

+ +
+ + +
+
+ +
+
+ + -
+

{{ $t("extra") }}

@@ -164,7 +237,7 @@ >
-
+

{{ stack.composeFileName }}

@@ -400,9 +473,9 @@ export default { url() { if (this.stack.endpoint) { - return `/compose/${this.stack.name}/${this.stack.endpoint}`; + return `/compose/${encodeURIComponent(this.stack.name)}/${this.stack.endpoint}`; } else { - return `/compose/${this.stack.name}`; + return `/compose/${encodeURIComponent(this.stack.name)}`; } }, }, @@ -485,9 +558,9 @@ export default { } else { this.stack.name = this.$route.params.stackName; this.loadStack(); + this.requestServiceStatus(); } - this.requestServiceStatus(); this.requestDockerStats(); }, unmounted() { @@ -576,45 +649,59 @@ export default { deployStack() { this.processing = true; + this.bindTerminal(); - if (!this.jsonConfig.services) { - this.$root.toastError("No services found in compose.yaml"); - this.processing = false; - return; - } + if (this.stack.isGitRepo) { + this.$root.emitAgent(this.stack.endpoint, "gitDeployStack", this.stack.name, this.stack.gitUrl, this.stack.branch, this.isAdd, (res) => { + this.processing = false; + this.$root.toastRes(res); - // Check if services is object - if (typeof this.jsonConfig.services !== "object") { - this.$root.toastError("Services must be an object"); - this.processing = false; - return; - } + if (res.ok) { + this.isEditMode = false; + this.$router.push(this.url); + } - let serviceNameList = Object.keys(this.jsonConfig.services); + }); - // Set the stack name if empty, use the first container name - if (!this.stack.name && serviceNameList.length > 0) { - let serviceName = serviceNameList[0]; - let service = this.jsonConfig.services[serviceName]; + } else { + if (!this.jsonConfig.services) { + this.$root.toastError("No services found in compose.yaml"); + this.processing = false; + return; + } - if (service && service.container_name) { - this.stack.name = service.container_name; - } else { - this.stack.name = serviceName; + // Check if services is object + if (typeof this.jsonConfig.services !== "object") { + this.$root.toastError("Services must be an object"); + this.processing = false; + return; } - } - this.bindTerminal(); + let serviceNameList = Object.keys(this.jsonConfig.services); - this.$root.emitAgent(this.stack.endpoint, "deployStack", this.stack.name, this.stack.composeYAML, this.stack.composeENV, this.isAdd, (res) => { - this.processing = false; - this.$root.toastRes(res); + // Set the stack name if empty, use the first container name + if (!this.stack.name && serviceNameList.length > 0) { + let serviceName = serviceNameList[0]; + let service = this.jsonConfig.services[serviceName]; - if (res.ok) { - this.isEditMode = false; - this.$router.push(this.url); + if (service && service.container_name) { + this.stack.name = service.container_name; + } else { + this.stack.name = serviceName; + } } - }); + + this.$root.emitAgent(this.stack.endpoint, "deployStack", this.stack.name, this.stack.composeYAML, this.stack.composeENV, this.isAdd, (res) => { + this.processing = false; + this.$root.toastRes(res); + + if (res.ok) { + this.isEditMode = false; + this.$router.push(this.url); + } + }); + } + }, saveStack() { @@ -676,6 +763,15 @@ export default { }); }, + gitSync() { + this.processing = true; + + this.$root.emitAgent(this.endpoint, "gitSync", this.stack.name, (res) => { + this.processing = false; + this.$root.toastRes(res); + }); + }, + deleteDialog() { this.$root.emitAgent(this.endpoint, "deleteStack", this.stack.name, { deleteStackFiles: this.deleteStackFiles }, (res) => { this.$root.toastRes(res); @@ -855,6 +951,19 @@ export default { } }); }, + async copyWebhookToClipboard() { + try { + await navigator.clipboard.writeText(this.stack.webhook); + } catch (err) { + this.$root.toastError("Failed to copy to clipboard"); + } + this.$root.toastSuccess("Copied to clipboard"); + }, + + selectText(event) { + event.target.select(); + }, + } }; diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue index 82431bef..4e48ccd8 100644 --- a/frontend/src/pages/Settings.vue +++ b/frontend/src/pages/Settings.vue @@ -80,9 +80,15 @@ export default { appearance: { title: this.$t("Appearance"), }, + gitOps: { + title: this.$t("GitOps"), + }, security: { title: this.$t("Security"), }, + globalEnv: { + title: this.$t("GlobalEnv"), + }, about: { title: this.$t("About"), }, diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f3db7a6b..c204bff7 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -14,7 +14,9 @@ const Settings = () => import("./pages/Settings.vue"); import Appearance from "./components/settings/Appearance.vue"; import General from "./components/settings/General.vue"; const Security = () => import("./components/settings/Security.vue"); +const GlobalEnv = () => import("./components/settings/GlobalEnv.vue"); import About from "./components/settings/About.vue"; +import GitOps from "./components/settings/GitOps.vue"; const routes = [ { @@ -74,10 +76,18 @@ const routes = [ path: "appearance", component: Appearance, }, + { + path: "gitops", + component: GitOps, + }, { path: "security", component: Security, }, + { + path: "globalEnv", + component: GlobalEnv, + }, { path: "about", component: About, diff --git a/package.json b/package.json index 9df11833..e55a7b1b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "~4.21.2", "express-static-gzip": "~2.1.8", "http-graceful-shutdown": "~3.1.14", + "ini": "^4.1.2", "jsonwebtoken": "~9.0.2", "jwt-decode": "~3.1.2", "knex": "~2.5.1", @@ -68,6 +69,7 @@ "@types/bootstrap": "~5.2.10", "@types/command-exists": "~1.2.3", "@types/express": "~4.17.21", + "@types/ini": "^4.1.0", "@types/jsonwebtoken": "~9.0.7", "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "~6.8.0", @@ -98,4 +100,4 @@ "wait-on": "^7.2.0", "xterm-addon-web-links": "~0.9.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95ff084b..b00baff4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: http-graceful-shutdown: specifier: ~3.1.14 version: 3.1.14 + ini: + specifier: ^4.1.2 + version: 4.1.3 jsonwebtoken: specifier: ~9.0.2 version: 9.0.2 @@ -126,6 +129,9 @@ importers: '@types/express': specifier: ~4.17.21 version: 4.17.21 + '@types/ini': + specifier: ^4.1.0 + version: 4.1.1 '@types/jsonwebtoken': specifier: ~9.0.7 version: 9.0.7 @@ -879,6 +885,9 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/ini@4.1.1': + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1969,6 +1978,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3921,6 +3934,8 @@ snapshots: '@types/http-errors@2.0.4': {} + '@types/ini@4.1.1': {} + '@types/json-schema@7.0.15': {} '@types/jsonwebtoken@9.0.7': @@ -5290,6 +5305,8 @@ snapshots: ini@1.3.8: {} + ini@4.1.3: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0