Skip to content

Commit

Permalink
add git-managed stacks (pull only)
Browse files Browse the repository at this point in the history
  • Loading branch information
Felioh committed Mar 30, 2024
1 parent bbbc008 commit dff8abd
Show file tree
Hide file tree
Showing 13 changed files with 433 additions and 65 deletions.
38 changes: 38 additions & 0 deletions backend/agent-socket-handlers/docker-socket-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
61 changes: 60 additions & 1 deletion backend/dockge-server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -38,19 +39,23 @@ 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;
packageJSON : PackageJson;
io : socketIO.Server;
config : Config;
indexHTML : string = "";
gitUpdateInterval? : NodeJS.Timeout;

/**
* List of express routers
*/
routerList : Router[] = [
new MainRouter(),
new WebhookRouter(),
];

/**
Expand Down Expand Up @@ -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));
}

Expand All @@ -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,
Expand Down Expand Up @@ -398,6 +415,7 @@ export class DockgeServer {
});

checkVersion.startInterval();
this.startGitUpdater();
});

gracefulShutdown(this.httpServer, {
Expand Down Expand Up @@ -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<string[]> {
let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], {
encoding: "utf-8",
Expand Down
34 changes: 34 additions & 0 deletions backend/routers/webhook-router.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
60 changes: 58 additions & 2 deletions backend/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Check failure on line 23 in backend/stack.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18.17.1)

Could not find a declaration file for module 'ini'. '/home/runner/work/dockge/dockge/node_modules/.pnpm/[email protected]/node_modules/ini/lib/ini.js' implicitly has an 'any' type.

Check failure on line 23 in backend/stack.ts

View workflow job for this annotation

GitHub Actions / ci (ARM64, 18.17.1)

Could not find a declaration file for module 'ini'. '/tmp/runner/work/dockge/dockge/node_modules/.pnpm/[email protected]/node_modules/ini/lib/ini.js' implicitly has an 'any' type.

export class Stack {

Expand Down Expand Up @@ -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,
};
Expand All @@ -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;
}
Expand Down Expand Up @@ -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.");
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions frontend/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/components/settings/GitOps.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<template>
<div>
<form class="my-4" autocomplete="off" @submit.prevent="saveGitOps">
<!-- Enable Auto Updates -->
<div class="mb-4">
<label class="form-label" for="gitAutoUpdate">
{{ $t("gitAutoUpdate") }}
</label>

<div class="form-check form-switch my-3">
<input id="git-auto-update" v-model="settings.gitAutoUpdate" class="form-check-input" type="checkbox">
<label class="form-check-label">
{{ $t("enableAutoUpdate") }}
</label>
</div>

<div class="form-text"></div>
</div>

<!-- Save Button -->
<div>
<button class="btn btn-primary" type="submit">
{{ $t("Save") }}
</button>
</div>
</form>
</div>
</template>

<script>
export default {
data() {
return {};
},
computed: {
settings() {
return this.$parent.$parent.$parent.settings;
},
saveSettings() {
return this.$parent.$parent.$parent.saveSettings;
}
},
methods: {
/** Save the settings */
saveGitOps() {
this.saveSettings();
},
},
};
</script>
<style scoped lang="scss">
@import "../../styles/vars.scss";
</style>

2 changes: 1 addition & 1 deletion frontend/src/icon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ library.add(
faRocket,
faRotate,
faCloudArrowDown,
faArrowsRotate,
faArrowsRotate
);

export { FontAwesomeIcon };
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading

0 comments on commit dff8abd

Please sign in to comment.