diff --git a/backend/agent-socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts index 93abe851..4ff2a0ab 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 { 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) { @@ -24,6 +26,42 @@ export class DockerSocketHandler extends AgentSocketHandler { } }); + // clone git repo + agentSocket.on("gitDeploy", async (stackName : unknown, gitUrl : unknown, gitRef : 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(gitRef) !== "string") { + throw new ValidationError("Git Ref must be a string"); + } + + const terminalName = getComposeTerminalName(socket.endpoint, stackName); + + let exitCode = await Terminal.exec(server, socket, terminalName, "git", [ "clone", "-b", gitRef, gitUrl, stackName ], server.stacksDir); + if (exitCode !== 0) { + throw new Error("Failed to clone git repo"); + } + + const stack = await Stack.getStack(server, stackName); + await stack.start(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); diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index 8f734ccf..0aee9529 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; /** * List of express routers */ routerList : Router[] = [ new MainRouter(), + new WebhookRouter(), ]; /** @@ -183,6 +188,7 @@ export class DockgeServer { // Binding Routers for (const router of this.routerList) { + log.info("server", "Binding router: " + router.constructor.name); this.app.use(router.create(this.app, this)); } @@ -204,6 +210,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 +415,7 @@ export class DockgeServer { }); checkVersion.startInterval(); + this.startGitUpdater(); }); gracefulShutdown(this.httpServer, { @@ -610,6 +628,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); + } + async getDockerNetworkList() : Promise { let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], { encoding: "utf-8", diff --git a/backend/routers/webhook-router.ts b/backend/routers/webhook-router.ts new file mode 100644 index 00000000..07cf9895 --- /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.update(undefined); + + // Send a response + res.json({ message: `Updated stack: ${stackname}` }); + + } catch (error) { + _next(error); + } + }); + + return router; + } +} diff --git a/backend/stack.ts b/backend/stack.ts index fbce5002..53c6f2a8 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -19,6 +19,8 @@ import { import { InteractiveTerminal, Terminal } from "./terminal"; import childProcessAsync from "promisify-child-process"; import { Settings } from "./settings"; +import { exec } from "child_process"; +import ini from "ini"; export class Stack { @@ -84,6 +86,9 @@ export class Stack { status: this._status, tags: [], isManagedByDockge: this.isManagedByDockge, + isGitRepo: this.isGitRepo, + gitUrl: this.gitUrl, + webhook: this.webhook, composeFileName: this._composeFileName, endpoint, }; @@ -107,6 +112,27 @@ export class Stack { return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); } + get isGitRepo() : boolean { + return fs.existsSync(path.join(this.path, ".git")) && fs.statSync(path.join(this.path, ".git")).isDirectory(); + } + + get gitUrl() : string { + if (this.isGitRepo) { + const gitConfig = ini.parse(fs.readFileSync(path.join(this.path, ".git", "config"), "utf-8")); + return gitConfig["remote \"origin\""]?.url; + } + return ""; + } + + get webhook() : string { + //TODO: refine this. + if (this.server.config.hostname) { + return `http://${this.server.config.hostname}:${this.server.config.port}/webhook/update/${this.name}`; + } else { + return `http://localhost:${this.server.config.port}/webhook/update/${this.name}`; + } + } + get status() : number { return this._status; } @@ -443,8 +469,16 @@ export class Stack { return exitCode; } - async update(socket: DockgeSocket) { - const terminalName = getComposeTerminalName(socket.endpoint, this.name); + async update(socket?: DockgeSocket) { + const terminalName = socket ? getComposeTerminalName(socket.endpoint, this.name) : ""; + + if (this.isGitRepo) { + let exitCode = await Terminal.exec(this.server, socket, terminalName, "git", [ "pull", "--strategy-option", "theirs" ], 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."); @@ -464,6 +498,28 @@ export class Stack { 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 + exec("git fetch origin && git status -uno", { cwd: this.path }, (error, stdout, stderr) => { + if (error) { + log.error("checkRemoteChanges", error); + reject("Failed to check local status"); + return; + } + if (stdout.includes("Your branch is behind")) { + resolve(true); + } else { + resolve(false); + } + }); + }); + } + 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); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 708dd4e0..d93ecd42 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { Confirm: typeof import('./src/components/Confirm.vue')['default'] Container: typeof import('./src/components/Container.vue')['default'] General: typeof import('./src/components/settings/General.vue')['default'] + GitOps: typeof import('./src/components/settings/GitOps.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/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/icon.ts b/frontend/src/icon.ts index 0599e6af..727a7196 100644 --- a/frontend/src/icon.ts +++ b/frontend/src/icon.ts @@ -108,7 +108,7 @@ library.add( faRocket, faRotate, faCloudArrowDown, - faArrowsRotate, + faArrowsRotate ); export { FontAwesomeIcon }; diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 93bf53a1..188bb436 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -112,5 +112,13 @@ "agentRemovedSuccessfully": "Agent removed successfully.", "removeAgent": "Remove Agent", "removeAgentMsg": "Are you sure you want to remove this agent?", - "LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor." + "LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor.", + "repositoryUrl": "Repository URL", + "ref": "Ref", + "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" } diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index 7d378543..1a41b785 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -16,7 +16,7 @@ {{ $t("deployStack") }} - @@ -103,39 +103,48 @@ + + +
+ + +
-

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

- -
- - -
+
+

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

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

{{ $t("extra") }}

@@ -148,6 +157,62 @@
+ +
+

{{ $t("GitConfig") }}

+
+ +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+ +
+
+
+
+
+ +
+ + +
+
+
+
+

Terminal

@@ -162,7 +227,7 @@ >
-
+

{{ stack.composeFileName }}

@@ -545,45 +610,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, "gitDeploy", this.stack.name, this.stack.gitUrl, this.stack.gitRef, (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() { @@ -645,6 +724,15 @@ export default { }); }, + gitUpdate() { + this.processing = true; + + this.$root.emitAgent(this.endpoint, "pullGitRepo", this.stack.name, (res) => { + this.processing = false; + this.$root.toastRes(res); + }); + }, + deleteDialog() { this.$root.emitAgent(this.endpoint, "deleteStack", this.stack.name, (res) => { this.$root.toastRes(res); @@ -786,6 +874,19 @@ export default { this.stack.name = this.stack?.name?.toLowerCase(); }, + 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..9f58ce81 100644 --- a/frontend/src/pages/Settings.vue +++ b/frontend/src/pages/Settings.vue @@ -80,6 +80,9 @@ export default { appearance: { title: this.$t("Appearance"), }, + gitOps: { + title: this.$t("GitOps"), + }, security: { title: this.$t("Security"), }, diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f3db7a6b..0efa3fbf 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -15,6 +15,7 @@ import Appearance from "./components/settings/Appearance.vue"; import General from "./components/settings/General.vue"; const Security = () => import("./components/settings/Security.vue"); import About from "./components/settings/About.vue"; +import GitOps from "./components/settings/GitOps.vue"; const routes = [ { @@ -74,6 +75,10 @@ const routes = [ path: "appearance", component: Appearance, }, + { + path: "gitops", + component: GitOps, + }, { path: "security", component: Security, @@ -81,7 +86,7 @@ const routes = [ { path: "about", component: About, - }, + } ] }, ] diff --git a/package.json b/package.json index abd3a863..b471088f 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "~4.18.2", "express-static-gzip": "~2.1.7", "http-graceful-shutdown": "~3.1.13", + "ini": "^4.1.2", "jsonwebtoken": "~9.0.2", "jwt-decode": "~3.1.2", "knex": "~2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fa1f02b..2a9f49d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ dependencies: http-graceful-shutdown: specifier: ~3.1.13 version: 3.1.13 + ini: + specifier: ^4.1.2 + version: 4.1.2 jsonwebtoken: specifier: ~9.0.2 version: 9.0.2 @@ -903,9 +906,6 @@ packages: /@louislam/sqlite3@15.1.6: resolution: {integrity: sha512-cVf7hcMrfywYnycatLvorngTFpL3BSWvEy7/NrEfcTyQX8xxj9fdeD553oCTv5fIAk85fluo6mzPq89V3YzrVA==} requiresBuild: true - peerDependenciesMeta: - node-gyp: - optional: true dependencies: '@mapbox/node-pre-gyp': 1.0.11 node-addon-api: 4.3.0 @@ -3305,6 +3305,11 @@ packages: requiresBuild: true dev: false + /ini@4.1.2: + resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dev: false + /internal-slot@1.0.6: resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==} engines: {node: '>= 0.4'}