Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GitOps - Stacks managed by Git #471

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 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,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);
Expand Down Expand Up @@ -188,6 +231,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 {
Expand Down
60 changes: 59 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 @@ -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,
Expand Down Expand Up @@ -398,6 +414,7 @@ export class DockgeServer {
});

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

gracefulShutdown(this.httpServer, {
Expand Down Expand Up @@ -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);
}

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) => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if i know or find out the stack name I can update it a few hundred times a day, just for fun..? 😉 Usually webhooks are kind of "masked" by using a random string.

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;
}
}
88 changes: 88 additions & 0 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 { execSync } from "child_process";
import ini from "ini";

export class Stack {

Expand Down Expand Up @@ -84,6 +86,10 @@ export class Stack {
status: this._status,
tags: [],
isManagedByDockge: this.isManagedByDockge,
isGitRepo: this.isGitRepo,
gitUrl: this.gitUrl,
branch: this.branch,
webhook: this.webhook,
composeFileName: this._composeFileName,
endpoint,
};
Expand All @@ -107,6 +113,39 @@ 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 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/${this.name}`;
} else {
return `http://localhost:${this.server.config.port}/webhook/update/${this.name}`;
}
}

get status() : number {
return this._status;
}
Expand Down Expand Up @@ -445,6 +484,7 @@ 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" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to pull, please check the terminal output for more information.");
Expand All @@ -464,6 +504,54 @@ export class Stack {
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);
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
7 changes: 7 additions & 0 deletions frontend/src/components/StackListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
<span>{{ stackName }}</span>
<div v-if="$root.agentCount > 1" class="endpoint">{{ endpointDisplay }}</div>
</div>
<div class="icon-container">
<font-awesome-icon :icon="stack.isGitRepo ? 'code-branch' : 'file'" />
</div>
</router-link>
</template>

Expand Down Expand Up @@ -178,4 +181,8 @@ export default {
opacity: 0.5;
}

.icon-container {
margin-left: auto;
}

</style>
Loading
Loading