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 Apr 1, 2024
1 parent c7ea2f9 commit ce79965
Show file tree
Hide file tree
Showing 14 changed files with 515 additions and 62 deletions.
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");
}

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

0 comments on commit ce79965

Please sign in to comment.