add git-managed stacks (pull only)

This commit is contained in:
Felix Ohnesorge 2024-03-30 21:40:58 +01:00
parent 8e362f3fa9
commit 26f8602e84
14 changed files with 889 additions and 402 deletions

View file

@ -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) {
@ -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");
}
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);
@ -196,6 +239,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 {

View file

@ -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(),
];
/**
@ -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);
}
async getDockerNetworkList() : Promise<string[]> {
let res = await childProcessAsync.spawn("docker", [ "network", "ls", "--format", "{{.Name}}" ], {
encoding: "utf-8",

View file

@ -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;
}
}

View file

@ -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 {
@ -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,
};
@ -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;
}
@ -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.");
@ -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);

View file

@ -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']

View file

@ -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>
@ -178,4 +181,8 @@ export default {
opacity: 0.5;
}
.icon-container {
margin-left: auto;
}
</style>

View file

@ -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>

View file

@ -54,6 +54,8 @@ import {
faTerminal, faWarehouse, faHome, faRocket,
faRotate,
faCloudArrowDown, faArrowsRotate,
faCodeBranch,
faFileCode,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@ -109,6 +111,8 @@ library.add(
faRotate,
faCloudArrowDown,
faArrowsRotate,
faCodeBranch,
faFileCode,
);
export { FontAwesomeIcon };

View file

@ -128,5 +128,15 @@
"New Container Name...": "New Container Name...",
"Network name...": "Network name...",
"Select a network...": "Select a network...",
"NoNetworksAvailable": "No networks available. You need to add internal networks or enable external networks in the right side first."
}
"NoNetworksAvailable": "No networks available. You need to add internal networks or enable external networks in the right side first.",
"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"
}

View file

@ -16,7 +16,7 @@
{{ $t("deployStack") }}
</button>
<button v-if="isEditMode" class="btn btn-normal" :disabled="processing" @click="saveStack">
<button v-if="isEditMode && !stack.isGitRepo" class="btn btn-normal" :disabled="processing" @click="saveStack">
<font-awesome-icon icon="save" class="me-1" />
{{ $t("saveStackDraft") }}
</button>
@ -41,6 +41,11 @@
{{ $t("updateStack") }}
</button>
<button v-if="!isEditMode && stack.isGitRepo" class="btn btn-normal" :disabled="processing" @click="gitSync">
<font-awesome-icon icon="rotate" class="me-1" />
{{ $t("gitSync") }}
</button>
<button v-if="!isEditMode && active" class="btn btn-normal" :disabled="processing" @click="stopStack">
<font-awesome-icon icon="stop" class="me-1" />
{{ $t("stopStack") }}
@ -103,39 +108,117 @@
</option>
</select>
</div>
<!-- Manage with git -->
<div class="form-check form-switch my-3">
<input id="gitops" v-model="stack.isGitRepo" class="form-check-input" type="checkbox">
<label class="form-check-label">
{{ $t("ManageWithGit") }}
</label>
</div>
</div>
</div>
<!-- GitOps -->
<div v-if="stack.isGitRepo">
<h4 class="mb-3">{{ $t("GitConfig") }}</h4>
<div class="shadow-box big-padding mb-3">
<!-- Repo URL -->
<div v-if="isEditMode" class="mb-4">
<label class="form-label">
{{ $t("repositoryUrl") }}
</label>
<div class="input-group mb-3">
<input
v-model="stack.gitUrl"
class="form-control"
placeholder="https://"
/>
</div>
<div class="form-text"></div>
</div>
<!-- branch -->
<div class="mb-4">
<label class="form-label">
{{ $t("branch") }}
</label>
<div class="input-group mb-3">
<span v-if="!isEditMode" class="form-control">{{ stack.branch }}</span>
<input
v-else
v-model="stack.branch"
class="form-control"
placeholder="main"
/>
</div>
<div class="form-text"></div>
</div>
<!-- Webhook -->
<div v-if="!isAdd" class="mb-4">
<label class="form-label">
{{ $t("webhook") }}
</label>
<div class="input-group mb-3">
<input
v-model="stack.webhook"
class="form-control"
readonly
@click="selectText"
/>
<button class="btn btn-outline-primary" type="button" @click="copyWebhookToClipboard">
{{ $t("copy") }}
</button>
</div>
</div>
</div>
</div>
<!-- Containers -->
<h4 class="mb-3">{{ $tc("container", 2) }}</h4>
<div v-if="!stack.isGitRepo || (stack.isGitRepo && !isEditMode)">
<h4 class="mb-3">{{ $tc("container", 2) }}</h4>
<div v-if="isEditMode" class="input-group mb-3">
<input
v-model="newContainerName"
:placeholder="$t(`New Container Name...`)"
class="form-control"
@keyup.enter="addContainer"
/>
<button class="btn btn-primary" @click="addContainer">
{{ $t("addContainer") }}
</button>
<div v-if="isEditMode" class="input-group mb-3">
<input
v-model="newContainerName"
:placeholder="$t(`New Container Name...`)"
class="form-control"
@keyup.enter="addContainer"
/>
<button class="btn btn-primary" @click="addContainer">
{{ $t("addContainer") }}
</button>
</div>
<div v-if="isEditMode && !stack.isGitRepo" class="input-group mb-3">
<input
v-model="newContainerName"
:placeholder="$t(`New Container Name...`)"
class="form-control"
@keyup.enter="addContainer"
/>
<button class="btn btn-primary" @click="addContainer">
{{ $t("addContainer") }}
</button>
</div>
<div ref="containerList">
<Container
v-for="(service, name) in jsonConfig.services"
:key="name"
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
/>
</div>
</div>
<div ref="containerList">
<Container
v-for="(service, name) in jsonConfig.services"
:key="name"
:name="name"
:is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]"
:status="serviceStatusList[name]"
/>
</div>
<button v-if="false && isEditMode && jsonConfig.services && Object.keys(jsonConfig.services).length > 0" class="btn btn-normal mb-3" @click="addContainer">{{ $t("addContainer") }}</button>
<button v-if="false && isEditMode && !stack.isGitRepo && jsonConfig.services && Object.keys(jsonConfig.services).length > 0" class="btn btn-normal mb-3" @click="addContainer">{{ $t("addContainer") }}</button>
<!-- General -->
<div v-if="isEditMode">
<div v-if="isEditMode && !stack.isGitRepo">
<h4 class="mb-3">{{ $t("extra") }}</h4>
<div class="shadow-box big-padding mb-3">
<!-- URLs -->
@ -162,7 +245,7 @@
></Terminal>
</div>
</div>
<div class="col-lg-6">
<div v-if="!stack.isGitRepo || (stack.isGitRepo && !isEditMode)" class="col-lg-6">
<h4 class="mb-3">{{ stack.composeFileName }}</h4>
<!-- YAML editor -->
@ -545,45 +628,59 @@ export default {
deployStack() {
this.processing = true;
if (!this.jsonConfig.services) {
this.$root.toastError("No services found in compose.yaml");
this.processing = false;
return;
}
// Check if services is object
if (typeof this.jsonConfig.services !== "object") {
this.$root.toastError("Services must be an object");
this.processing = false;
return;
}
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];
if (service && service.container_name) {
this.stack.name = service.container_name;
} else {
this.stack.name = serviceName;
}
}
this.bindTerminal();
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 (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);
if (res.ok) {
this.isEditMode = false;
this.$router.push(this.url);
if (res.ok) {
this.isEditMode = false;
this.$router.push(this.url);
}
});
} else {
if (!this.jsonConfig.services) {
this.$root.toastError("No services found in compose.yaml");
this.processing = false;
return;
}
});
// Check if services is object
if (typeof this.jsonConfig.services !== "object") {
this.$root.toastError("Services must be an object");
this.processing = false;
return;
}
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];
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 +742,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, (res) => {
this.$root.toastRes(res);
@ -786,6 +892,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();
},
}
};
</script>

View file

@ -80,6 +80,9 @@ export default {
appearance: {
title: this.$t("Appearance"),
},
gitOps: {
title: this.$t("GitOps"),
},
security: {
title: this.$t("Security"),
},

View file

@ -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,
},
}
]
},
]

View file

@ -41,6 +41,7 @@
"express": "~4.21.1",
"express-static-gzip": "~2.1.8",
"http-graceful-shutdown": "~3.1.13",
"ini": "^4.1.2",
"jsonwebtoken": "~9.0.2",
"jwt-decode": "~3.1.2",
"knex": "~2.5.1",
@ -70,6 +71,7 @@
"@types/express": "~4.17.21",
"@types/jsonwebtoken": "~9.0.7",
"@types/semver": "^7.5.8",
"@types/ini": "^4.1.0",
"@typescript-eslint/eslint-plugin": "~6.8.0",
"@typescript-eslint/parser": "~6.8.0",
"@vitejs/plugin-vue": "~4.5.2",
@ -98,4 +100,4 @@
"wait-on": "^7.2.0",
"xterm-addon-web-links": "~0.9.0"
}
}
}

File diff suppressed because it is too large Load diff