added support for multiple stacks in repos

This commit is contained in:
Felix Ohnesorge 2024-11-16 12:19:21 +01:00
parent b946b11f5f
commit 26bd199c69
7 changed files with 503 additions and 171 deletions

51
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,51 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Dev with Debugger",
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 9229,
"env": {
"NODE_ENV": "development"
},
"console": "integratedTerminal",
"sourceMaps": true,
"smartStep": true
},
{
"name": "Launch Backend",
"type": "node",
"request": "launch",
"runtimeExecutable": "ts-node",
"args": ["${workspaceFolder}/backend/dockge-server.ts"],
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"env": {
"NODE_ENV": "development"
},
"sourceMaps": true,
"smartStep": true
},
{
"name": "Launch Frontend",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/frontend",
"preLaunchTask": "npm: start"
},
{
"name": "Attach to Backend",
"type": "node",
"request": "attach",
"port": 9229,
"restart": true,
"protocol": "inspector",
"sourceMaps": true,
"smartStep": true
}
]
}

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"editor.formatOnSave": false,
"editor.formatOnType": false,
"editor.formatOnPaste": false
}

View file

@ -12,21 +12,20 @@ import dayjs, { Dayjs } from "dayjs";
* One AgentManager per Socket connection * One AgentManager per Socket connection
*/ */
export class AgentManager { export class AgentManager {
protected socket: DockgeSocket;
protected socket : DockgeSocket; protected agentSocketList: Record<string, SocketClient> = {};
protected agentSocketList : Record<string, SocketClient> = {}; protected agentLoggedInList: Record<string, boolean> = {};
protected agentLoggedInList : Record<string, boolean> = {}; protected _firstConnectTime: Dayjs = dayjs();
protected _firstConnectTime : Dayjs = dayjs();
constructor(socket: DockgeSocket) { constructor(socket: DockgeSocket) {
this.socket = socket; this.socket = socket;
} }
get firstConnectTime() : Dayjs { get firstConnectTime(): Dayjs {
return this._firstConnectTime; return this._firstConnectTime;
} }
test(url : string, username : string, password : string) : Promise<void> { test(url: string, username: string, password: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let obj = new URL(url); let obj = new URL(url);
let endpoint = obj.host; let endpoint = obj.host;
@ -43,26 +42,32 @@ export class AgentManager {
reconnection: false, reconnection: false,
extraHeaders: { extraHeaders: {
endpoint, endpoint,
} },
}); });
client.on("connect", () => { client.on("connect", () => {
client.emit("login", { client.emit(
username: username, "login",
password: password, {
}, (res : LooseObject) => { username: username,
if (res.ok) { password: password,
resolve(); },
} else { (res: LooseObject) => {
reject(new Error(res.msg)); if (res.ok) {
resolve();
} else {
reject(new Error(res.msg));
}
client.disconnect();
} }
client.disconnect(); );
});
}); });
client.on("connect_error", (err) => { client.on("connect_error", (err) => {
if (err.message === "xhr poll error") { if (err.message === "xhr poll error") {
reject(new Error("Unable to connect to the Dockge instance")); reject(
new Error("Unable to connect to the Dockge instance")
);
} else { } else {
reject(err); reject(err);
} }
@ -77,7 +82,7 @@ export class AgentManager {
* @param username * @param username
* @param password * @param password
*/ */
async add(url : string, username : string, password : string) : Promise<Agent> { async add(url: string, username: string, password: string): Promise<Agent> {
let bean = R.dispense("agent") as Agent; let bean = R.dispense("agent") as Agent;
bean.url = url; bean.url = url;
bean.username = username; bean.username = username;
@ -90,10 +95,8 @@ export class AgentManager {
* *
* @param url * @param url
*/ */
async remove(url : string) { async remove(url: string) {
let bean = await R.findOne("agent", " url = ? ", [ let bean = await R.findOne("agent", " url = ? ", [url]);
url,
]);
if (bean) { if (bean) {
await R.trash(bean); await R.trash(bean);
@ -106,7 +109,7 @@ export class AgentManager {
} }
} }
connect(url : string, username : string, password : string) { connect(url: string, username: string, password: string) {
let obj = new URL(url); let obj = new URL(url);
let endpoint = obj.host; let endpoint = obj.host;
@ -116,49 +119,74 @@ export class AgentManager {
}); });
if (!endpoint) { if (!endpoint) {
log.error("agent-manager", "Invalid endpoint: " + endpoint + " URL: " + url); log.error(
"agent-manager",
"Invalid endpoint: " + endpoint + " URL: " + url
);
return; return;
} }
if (this.agentSocketList[endpoint]) { if (this.agentSocketList[endpoint]) {
log.debug("agent-manager", "Already connected to the socket server: " + endpoint); log.debug(
"agent-manager",
"Already connected to the socket server: " + endpoint
);
return; return;
} }
log.info("agent-manager", "Connecting to the socket server: " + endpoint); log.info(
"agent-manager",
"Connecting to the socket server: " + endpoint
);
let client = io(url, { let client = io(url, {
extraHeaders: { extraHeaders: {
endpoint, endpoint,
} },
}); });
client.on("connect", () => { client.on("connect", () => {
log.info("agent-manager", "Connected to the socket server: " + endpoint); log.info(
"agent-manager",
"Connected to the socket server: " + endpoint
);
client.emit("login", { client.emit(
username: username, "login",
password: password, {
}, (res : LooseObject) => { username: username,
if (res.ok) { password: password,
log.info("agent-manager", "Logged in to the socket server: " + endpoint); },
this.agentLoggedInList[endpoint] = true; (res: LooseObject) => {
this.socket.emit("agentStatus", { if (res.ok) {
endpoint: endpoint, log.info(
status: "online", "agent-manager",
}); "Logged in to the socket server: " + endpoint
} else { );
log.error("agent-manager", "Failed to login to the socket server: " + endpoint); this.agentLoggedInList[endpoint] = true;
this.agentLoggedInList[endpoint] = false; this.socket.emit("agentStatus", {
this.socket.emit("agentStatus", { endpoint: endpoint,
endpoint: endpoint, status: "online",
status: "offline", });
}); } else {
log.error(
"agent-manager",
"Failed to login to the socket server: " + endpoint
);
this.agentLoggedInList[endpoint] = false;
this.socket.emit("agentStatus", {
endpoint: endpoint,
status: "offline",
});
}
} }
}); );
}); });
client.on("connect_error", (err) => { client.on("connect_error", (err) => {
log.error("agent-manager", "Error from the socket server: " + endpoint); log.error(
"agent-manager",
"Error from the socket server: " + endpoint
);
this.socket.emit("agentStatus", { this.socket.emit("agentStatus", {
endpoint: endpoint, endpoint: endpoint,
status: "offline", status: "offline",
@ -166,14 +194,17 @@ export class AgentManager {
}); });
client.on("disconnect", () => { client.on("disconnect", () => {
log.info("agent-manager", "Disconnected from the socket server: " + endpoint); log.info(
"agent-manager",
"Disconnected from the socket server: " + endpoint
);
this.socket.emit("agentStatus", { this.socket.emit("agentStatus", {
endpoint: endpoint, endpoint: endpoint,
status: "offline", status: "offline",
}); });
}); });
client.on("agent", (...args : unknown[]) => { client.on("agent", (...args: unknown[]) => {
this.socket.emit("agent", ...args); this.socket.emit("agent", ...args);
}); });
@ -194,7 +225,7 @@ export class AgentManager {
this.agentSocketList[endpoint] = client; this.agentSocketList[endpoint] = client;
} }
disconnect(endpoint : string) { disconnect(endpoint: string) {
let client = this.agentSocketList[endpoint]; let client = this.agentSocketList[endpoint];
client?.disconnect(); client?.disconnect();
} }
@ -203,14 +234,20 @@ export class AgentManager {
this._firstConnectTime = dayjs(); this._firstConnectTime = dayjs();
if (this.socket.endpoint) { if (this.socket.endpoint) {
log.info("agent-manager", "This connection is connected as an agent, skip connectAll()"); log.info(
"agent-manager",
"This connection is connected as an agent, skip connectAll()"
);
return; return;
} }
let list : Record<string, Agent> = await Agent.getAgentList(); let list: Record<string, Agent> = await Agent.getAgentList();
if (Object.keys(list).length !== 0) { if (Object.keys(list).length !== 0) {
log.info("agent-manager", "Connecting to all instance socket server(s)..."); log.info(
"agent-manager",
"Connecting to all instance socket server(s)..."
);
} }
for (let endpoint in list) { for (let endpoint in list) {
@ -225,13 +262,22 @@ export class AgentManager {
} }
} }
async emitToEndpoint(endpoint: string, eventName: string, ...args : unknown[]) { async emitToEndpoint(
endpoint: string,
eventName: string,
...args: unknown[]
) {
log.debug("agent-manager", "Emitting event to endpoint: " + endpoint); log.debug("agent-manager", "Emitting event to endpoint: " + endpoint);
let client = this.agentSocketList[endpoint]; let client = this.agentSocketList[endpoint];
if (!client) { if (!client) {
log.error("agent-manager", "Socket client not found for endpoint: " + endpoint); log.error(
throw new Error("Socket client not found for endpoint: " + endpoint); "agent-manager",
"Socket client not found for endpoint: " + endpoint
);
throw new Error(
"Socket client not found for endpoint: " + endpoint
);
} }
if (!client.connected || !this.agentLoggedInList[endpoint]) { if (!client.connected || !this.agentLoggedInList[endpoint]) {
@ -242,25 +288,36 @@ export class AgentManager {
let ok = false; let ok = false;
while (diff < 10) { while (diff < 10) {
if (client.connected && this.agentLoggedInList[endpoint]) { if (client.connected && this.agentLoggedInList[endpoint]) {
log.debug("agent-manager", `${endpoint}: Connected & Logged in`); log.debug(
"agent-manager",
`${endpoint}: Connected & Logged in`
);
ok = true; ok = true;
break; break;
} }
log.debug("agent-manager", endpoint + ": not ready yet, retrying in 1 second..."); log.debug(
"agent-manager",
endpoint + ": not ready yet, retrying in 1 second..."
);
await sleep(1000); await sleep(1000);
diff = dayjs().diff(this.firstConnectTime, "second"); diff = dayjs().diff(this.firstConnectTime, "second");
} }
if (!ok) { if (!ok) {
log.error("agent-manager", `${endpoint}: Socket client not connected`); log.error(
throw new Error("Socket client not connected for endpoint: " + endpoint); "agent-manager",
`${endpoint}: Socket client not connected`
);
throw new Error(
"Socket client not connected for endpoint: " + endpoint
);
} }
} }
client.emit("agent", endpoint, eventName, ...args); client.emit("agent", endpoint, eventName, ...args);
} }
emitToAllEndpoints(eventName: string, ...args : unknown[]) { emitToAllEndpoints(eventName: string, ...args: unknown[]) {
log.debug("agent-manager", "Emitting event to all endpoints"); log.debug("agent-manager", "Emitting event to all endpoints");
for (let endpoint in this.agentSocketList) { for (let endpoint in this.agentSocketList) {
this.emitToEndpoint(endpoint, eventName, ...args).catch((e) => { this.emitToEndpoint(endpoint, eventName, ...args).catch((e) => {
@ -271,7 +328,7 @@ export class AgentManager {
async sendAgentList() { async sendAgentList() {
let list = await Agent.getAgentList(); let list = await Agent.getAgentList();
let result : Record<string, LooseObject> = {}; let result: Record<string, LooseObject> = {};
// Myself // Myself
result[""] = { result[""] = {

View file

@ -48,7 +48,7 @@ export class DockgeServer {
io : socketIO.Server; io : socketIO.Server;
config : Config; config : Config;
indexHTML : string = ""; indexHTML : string = "";
gitUpdateInterval? : NodeJS.Timeout; gitUpdateInterval : NodeJS.Timeout | undefined;
/** /**
* List of express routers * List of express routers
@ -665,7 +665,7 @@ export class DockgeServer {
}; };
await check(); await check();
this.gitUpdateInterval = setInterval(check, GIT_UPDATE_CHECKER_INTERVAL_MS); this.gitUpdateInterval = setInterval(check, GIT_UPDATE_CHECKER_INTERVAL_MS) as NodeJS.Timeout;
} }
async getDockerNetworkList() : Promise<string[]> { async getDockerNetworkList() : Promise<string[]> {
@ -702,6 +702,10 @@ export class DockgeServer {
log.info("server", "Shutdown requested"); log.info("server", "Shutdown requested");
log.info("server", "Called signal: " + signal); log.info("server", "Called signal: " + signal);
if (this.gitUpdateInterval) {
clearInterval(this.gitUpdateInterval);
}
// TODO: Close all terminals? // TODO: Close all terminals?
await Database.close(); await Database.close();

View file

@ -10,11 +10,14 @@ import {
COMBINED_TERMINAL_ROWS, COMBINED_TERMINAL_ROWS,
CREATED_FILE, CREATED_FILE,
CREATED_STACK, CREATED_STACK,
EXITED, getCombinedTerminalName, EXITED,
getComposeTerminalName, getContainerExecTerminalName, getCombinedTerminalName,
getComposeTerminalName,
getContainerExecTerminalName,
PROGRESS_TERMINAL_ROWS, PROGRESS_TERMINAL_ROWS,
RUNNING, TERMINAL_ROWS, RUNNING,
UNKNOWN TERMINAL_ROWS,
UNKNOWN,
} from "../common/util-common"; } from "../common/util-common";
import { InteractiveTerminal, Terminal } from "./terminal"; import { InteractiveTerminal, Terminal } from "./terminal";
import childProcessAsync from "promisify-child-process"; import childProcessAsync from "promisify-child-process";
@ -23,7 +26,6 @@ import { execSync } from "child_process";
import ini from "ini"; import ini from "ini";
export class Stack { export class Stack {
name: string; name: string;
protected _status: number = UNKNOWN; protected _status: number = UNKNOWN;
protected _composeYAML?: string; protected _composeYAML?: string;
@ -32,11 +34,17 @@ export class Stack {
protected _composeFileName: string = "compose.yaml"; protected _composeFileName: string = "compose.yaml";
protected server: DockgeServer; protected server: DockgeServer;
protected combinedTerminal? : Terminal; protected combinedTerminal?: Terminal;
protected static managedStackList: Map<string, Stack> = new Map(); protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string, composeENV? : string, skipFSOperations = false) { constructor(
server: DockgeServer,
name: string,
composeYAML?: string,
composeENV?: string,
skipFSOperations = false
) {
this.name = name; this.name = name;
this.server = server; this.server = server;
this._composeYAML = composeYAML; this._composeYAML = composeYAML;
@ -53,8 +61,7 @@ export class Stack {
} }
} }
async toJSON(endpoint : string) : Promise<object> { async toJSON(endpoint: string): Promise<object> {
// Since we have multiple agents now, embed primary hostname in the stack object too. // Since we have multiple agents now, embed primary hostname in the stack object too.
let primaryHostname = await Settings.get("primaryHostname"); let primaryHostname = await Settings.get("primaryHostname");
if (!primaryHostname) { if (!primaryHostname) {
@ -63,7 +70,7 @@ export class Stack {
} else { } else {
// Use the endpoint as the primary hostname // Use the endpoint as the primary hostname
try { try {
primaryHostname = (new URL("https://" + endpoint).hostname); primaryHostname = new URL("https://" + endpoint).hostname;
} catch (e) { } catch (e) {
// Just in case if the endpoint is in a incorrect format // Just in case if the endpoint is in a incorrect format
primaryHostname = "localhost"; primaryHostname = "localhost";
@ -80,7 +87,7 @@ export class Stack {
}; };
} }
toSimpleJSON(endpoint : string) : object { toSimpleJSON(endpoint: string): object {
return { return {
name: this.name, name: this.name,
status: this._status, status: this._status,
@ -98,29 +105,42 @@ export class Stack {
/** /**
* Get the status of the stack from `docker compose ps --format json` * Get the status of the stack from `docker compose ps --format json`
*/ */
async ps() : Promise<object> { async ps(): Promise<object> {
let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { let res = await childProcessAsync.spawn(
cwd: this.path, "docker",
encoding: "utf-8", ["compose", "ps", "--format", "json"],
}); {
cwd: this.path,
encoding: "utf-8",
}
);
if (!res.stdout) { if (!res.stdout) {
return {}; return {};
} }
return JSON.parse(res.stdout.toString()); return JSON.parse(res.stdout.toString());
} }
get isManagedByDockge() : boolean { get isManagedByDockge(): boolean {
return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
} }
get isGitRepo() : boolean { get isGitRepo(): boolean {
return fs.existsSync(path.join(this.path, ".git")) && fs.statSync(path.join(this.path, ".git")).isDirectory(); try {
execSync("git rev-parse --is-inside-work-tree", { cwd: this.path });
return true;
} catch (error) {
return false;
}
} }
get gitUrl() : string { get gitUrl() : string {
if (this.isGitRepo) { if (this.isGitRepo) {
const gitConfig = ini.parse(fs.readFileSync(path.join(this.path, ".git", "config"), "utf-8")); try {
return gitConfig["remote \"origin\""]?.url; let stdout = execSync("git config --get remote.origin.url", { cwd: this.path });
return stdout.toString().trim();
} catch (error) {
return "";
}
} }
return ""; return "";
} }
@ -140,20 +160,22 @@ export class Stack {
get webhook() : string { get webhook() : string {
//TODO: refine this. //TODO: refine this.
if (this.server.config.hostname) { if (this.server.config.hostname) {
return `http://${this.server.config.hostname}:${this.server.config.port}/webhook/update/${this.name}`; return `http://${this.server.config.hostname}:${this.server.config.port}/webhook/update/${encodeURIComponent(this.name)}`;
} else { } else {
return `http://localhost:${this.server.config.port}/webhook/update/${this.name}`; return `http://localhost:${this.server.config.port}/webhook/update/${encodeURIComponent(this.name)}`;
} }
} }
get status() : number { get status(): number {
return this._status; return this._status;
} }
validate() { validate() {
// Check name, allows [a-z][0-9] _ - only // Check name, allows [a-z][0-9] _ - only
if (!this.name.match(/^[a-z0-9_-]+$/)) { if (!this.name.match(/^[a-z0-9_-]+$/)) {
throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only"); throw new ValidationError(
"Stack name can only contain [a-z][0-9] _ - only"
);
} }
// Check YAML format // Check YAML format
@ -164,15 +186,22 @@ export class Stack {
// Check if the .env is able to pass docker-compose // Check if the .env is able to pass docker-compose
// Prevent "setenv: The parameter is incorrect" // Prevent "setenv: The parameter is incorrect"
// It only happens when there is one line and it doesn't contain "=" // It only happens when there is one line and it doesn't contain "="
if (lines.length === 1 && !lines[0].includes("=") && lines[0].length > 0) { if (
lines.length === 1 &&
!lines[0].includes("=") &&
lines[0].length > 0
) {
throw new ValidationError("Invalid .env format"); throw new ValidationError("Invalid .env format");
} }
} }
get composeYAML() : string { get composeYAML(): string {
if (this._composeYAML === undefined) { if (this._composeYAML === undefined) {
try { try {
this._composeYAML = fs.readFileSync(path.join(this.path, this._composeFileName), "utf-8"); this._composeYAML = fs.readFileSync(
path.join(this.path, this._composeFileName),
"utf-8"
);
} catch (e) { } catch (e) {
this._composeYAML = ""; this._composeYAML = "";
} }
@ -180,10 +209,13 @@ export class Stack {
return this._composeYAML; return this._composeYAML;
} }
get composeENV() : string { get composeENV(): string {
if (this._composeENV === undefined) { if (this._composeENV === undefined) {
try { try {
this._composeENV = fs.readFileSync(path.join(this.path, ".env"), "utf-8"); this._composeENV = fs.readFileSync(
path.join(this.path, ".env"),
"utf-8"
);
} catch (e) { } catch (e) {
this._composeENV = ""; this._composeENV = "";
} }
@ -191,11 +223,11 @@ export class Stack {
return this._composeENV; return this._composeENV;
} }
get path() : string { get path(): string {
return path.join(this.server.stacksDir, this.name); return path.join(this.server.stacksDir, this.name);
} }
get fullPath() : string { get fullPath(): string {
let dir = this.path; let dir = this.path;
// Compose up via node-pty // Compose up via node-pty
@ -214,7 +246,7 @@ export class Stack {
* Save the stack to the disk * Save the stack to the disk
* @param isAdd * @param isAdd
*/ */
async save(isAdd : boolean) { async save(isAdd: boolean) {
this.validate(); this.validate();
let dir = this.path; let dir = this.path;
@ -228,43 +260,64 @@ export class Stack {
// Create the stack folder // Create the stack folder
await fsAsync.mkdir(dir); await fsAsync.mkdir(dir);
} else { } else {
if (!await fileExists(dir)) { if (!(await fileExists(dir))) {
throw new ValidationError("Stack not found"); throw new ValidationError("Stack not found");
} }
} }
// Write or overwrite the compose.yaml // Write or overwrite the compose.yaml
await fsAsync.writeFile(path.join(dir, this._composeFileName), this.composeYAML); await fsAsync.writeFile(
path.join(dir, this._composeFileName),
this.composeYAML
);
const envPath = path.join(dir, ".env"); const envPath = path.join(dir, ".env");
// Write or overwrite the .env // Write or overwrite the .env
// If .env is not existing and the composeENV is empty, we don't need to write it // If .env is not existing and the composeENV is empty, we don't need to write it
if (await fileExists(envPath) || this.composeENV.trim() !== "") { if ((await fileExists(envPath)) || this.composeENV.trim() !== "") {
await fsAsync.writeFile(envPath, this.composeENV); await fsAsync.writeFile(envPath, this.composeENV);
} }
} }
async deploy(socket : DockgeSocket) : Promise<number> { async deploy(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "up", "-d", "--remove-orphans"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to deploy, please check the terminal output for more information."); throw new Error(
"Failed to deploy, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
async delete(socket: DockgeSocket) : Promise<number> { async delete(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "down", "--remove-orphans"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to delete, please check the terminal output for more information."); throw new Error(
"Failed to delete, please check the terminal output for more information."
);
} }
// Remove the stack folder // Remove the stack folder
await fsAsync.rm(this.path, { await fsAsync.rm(this.path, {
recursive: true, recursive: true,
force: true force: true,
}); });
return exitCode; return exitCode;
@ -285,15 +338,13 @@ export class Stack {
* Checks if a compose file exists in the specified directory. * Checks if a compose file exists in the specified directory.
* @async * @async
* @static * @static
* @param {string} stacksDir - The directory of the stack. * @param {string} dir - The directory to search.
* @param {string} filename - The name of the directory to check for the compose file.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating whether any compose file exists. * @returns {Promise<boolean>} A promise that resolves to a boolean indicating whether any compose file exists.
*/ */
static async composeFileExists(stacksDir : string, filename : string) : Promise<boolean> { static async composeFileExists(dir: string): Promise<boolean> {
let filenamePath = path.join(stacksDir, filename);
// Check if any compose file exists // Check if any compose file exists
for (const filename of acceptedComposeFileNames) { for (const filename of acceptedComposeFileNames) {
let composeFile = path.join(filenamePath, filename); let composeFile = path.join(dir, filename);
if (await fileExists(composeFile)) { if (await fileExists(composeFile)) {
return true; return true;
} }
@ -301,9 +352,15 @@ export class Stack {
return false; return false;
} }
static async getStackList(server : DockgeServer, useCacheForManaged = false) : Promise<Map<string, Stack>> { static async getStackList(
server: DockgeServer,
useCacheForManaged = false,
depth = 0,
dir = ""
): Promise<Map<string, Stack>> {
let stacksDir = server.stacksDir; let stacksDir = server.stacksDir;
let stackList : Map<string, Stack>; let currentDir = dir ? path.join(stacksDir, dir) : stacksDir;
let stackList: Map<string, Stack>;
// Use cached stack list? // Use cached stack list?
if (useCacheForManaged && this.managedStackList.size > 0) { if (useCacheForManaged && this.managedStackList.size > 0) {
@ -312,25 +369,50 @@ export class Stack {
stackList = new Map<string, Stack>(); stackList = new Map<string, Stack>();
// Scan the stacks directory, and get the stack list // Scan the stacks directory, and get the stack list
let filenameList = await fsAsync.readdir(stacksDir); let filenameList = await fsAsync.readdir(currentDir);
for (let filename of filenameList) { for (let filename of filenameList) {
try { try {
// Check if it is a directory // Check if it is a directory
let stat = await fsAsync.stat(path.join(stacksDir, filename)); let stat = await fsAsync.stat(
path.join(currentDir, filename)
);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
continue; continue;
} }
// If no compose file exists, skip it // If no compose file exists, skip it
if (!await Stack.composeFileExists(stacksDir, filename)) { if (
continue; !(await Stack.composeFileExists(
path.join(currentDir, filename)
))
) {
if (depth >= 3) {
continue;
} else {
let subStackList = await this.getStackList(
server,
useCacheForManaged,
depth + 1,
path.join(dir, filename)
);
for (let [subFilename, subStack] of subStackList) {
stackList.set(subFilename, subStack);
}
continue;
}
} }
let stack = await this.getStack(server, filename); let stack = await this.getStack(
server,
path.join(dir, filename)
);
stack._status = CREATED_FILE; stack._status = CREATED_FILE;
stackList.set(filename, stack); stackList.set(path.join(dir, filename), stack);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`); log.warn(
"getStackList",
`Failed to get stack ${filename}, error: ${e.message}`
);
} }
} }
} }
@ -340,9 +422,13 @@ export class Stack {
} }
// Get status from docker compose ls // Get status from docker compose ls
let res = await childProcessAsync.spawn("docker", [ "compose", "ls", "--all", "--format", "json" ], { let res = await childProcessAsync.spawn(
encoding: "utf-8", "docker",
}); ["compose", "ls", "--all", "--format", "json"],
{
encoding: "utf-8",
}
);
if (!res.stdout) { if (!res.stdout) {
return stackList; return stackList;
@ -351,7 +437,21 @@ export class Stack {
let composeList = JSON.parse(res.stdout.toString()); let composeList = JSON.parse(res.stdout.toString());
for (let composeStack of composeList) { for (let composeStack of composeList) {
let stack = stackList.get(composeStack.Name); let stackName = composeStack.Name;
// Adjust the stack name based on the config file path
// Get the first config file path
let configFile = composeStack.ConfigFiles.split(",")[0];
// Just use the stack name if the config file path is not in the stacks directory
if (configFile.trim().startsWith(server.stacksDir) && acceptedComposeFileNames.some(file => configFile.trim().endsWith(file))) {
const relativePath = path.relative(server.stacksDir, configFile.trim());
const match = relativePath.match(new RegExp("^(.+)/(.+)\\.ya?ml$"));
if (match) {
stackName = match[1];
}
}
let stack = stackList.get(stackName);
// This stack probably is not managed by Dockge, but we still want to show it // This stack probably is not managed by Dockge, but we still want to show it
if (!stack) { if (!stack) {
@ -359,8 +459,8 @@ export class Stack {
if (composeStack.Name === "dockge") { if (composeStack.Name === "dockge") {
continue; continue;
} }
stack = new Stack(server, composeStack.Name); stack = new Stack(server, stackName);
stackList.set(composeStack.Name, stack); stackList.set(stackName, stack);
} }
stack._status = this.statusConvert(composeStack.Status); stack._status = this.statusConvert(composeStack.Status);
@ -374,12 +474,16 @@ export class Stack {
* Get the status list, it will be used to update the status of the stacks * Get the status list, it will be used to update the status of the stacks
* Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned
*/ */
static async getStatusList() : Promise<Map<string, number>> { static async getStatusList(): Promise<Map<string, number>> {
let statusList = new Map<string, number>(); let statusList = new Map<string, number>();
let res = await childProcessAsync.spawn("docker", [ "compose", "ls", "--all", "--format", "json" ], { let res = await childProcessAsync.spawn(
encoding: "utf-8", "docker",
}); ["compose", "ls", "--all", "--format", "json"],
{
encoding: "utf-8",
}
);
if (!res.stdout) { if (!res.stdout) {
return statusList; return statusList;
@ -388,7 +492,10 @@ export class Stack {
let composeList = JSON.parse(res.stdout.toString()); let composeList = JSON.parse(res.stdout.toString());
for (let composeStack of composeList) { for (let composeStack of composeList) {
statusList.set(composeStack.Name, this.statusConvert(composeStack.Status)); statusList.set(
composeStack.Name,
this.statusConvert(composeStack.Status)
);
} }
return statusList; return statusList;
@ -399,7 +506,7 @@ export class Stack {
* Input Example: "exited(1), running(1)" * Input Example: "exited(1), running(1)"
* @param status * @param status
*/ */
static statusConvert(status : string) : number { static statusConvert(status: string): number {
if (status.startsWith("created")) { if (status.startsWith("created")) {
return CREATED_STACK; return CREATED_STACK;
} else if (status.includes("exited")) { } else if (status.includes("exited")) {
@ -413,11 +520,18 @@ export class Stack {
} }
} }
static async getStack(server: DockgeServer, stackName: string, skipFSOperations = false) : Promise<Stack> { static async getStack(
server: DockgeServer,
stackName: string,
skipFSOperations = false
): Promise<Stack> {
let dir = path.join(server.stacksDir, stackName); let dir = path.join(server.stacksDir, stackName);
if (!skipFSOperations) { if (!skipFSOperations) {
if (!await fileExists(dir) || !(await fsAsync.stat(dir)).isDirectory()) { if (
!(await fileExists(dir)) ||
!(await fsAsync.stat(dir)).isDirectory()
) {
// Maybe it is a stack managed by docker compose directly // Maybe it is a stack managed by docker compose directly
let stackList = await this.getStackList(server, true); let stackList = await this.getStackList(server, true);
let stack = stackList.get(stackName); let stack = stackList.get(stackName);
@ -433,7 +547,7 @@ export class Stack {
//log.debug("getStack", "Skip FS operations"); //log.debug("getStack", "Skip FS operations");
} }
let stack : Stack; let stack: Stack;
if (!skipFSOperations) { if (!skipFSOperations) {
stack = new Stack(server, stackName); stack = new Stack(server, stackName);
@ -448,36 +562,72 @@ export class Stack {
async start(socket: DockgeSocket) { async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "up", "-d", "--remove-orphans"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to start, please check the terminal output for more information."); throw new Error(
"Failed to start, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
async stop(socket: DockgeSocket) : Promise<number> { async stop(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "stop"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to stop, please check the terminal output for more information."); throw new Error(
"Failed to stop, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
async restart(socket: DockgeSocket) : Promise<number> { async restart(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "restart"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information."); throw new Error(
"Failed to restart, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
async down(socket: DockgeSocket) : Promise<number> { async down(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path); let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "down"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to down, please check the terminal output for more information."); throw new Error(
"Failed to down, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
@ -485,9 +635,35 @@ export class Stack {
async update(socket: DockgeSocket) { async update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, this.name); const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path); if (this.isGitRepo) {
// TODO: error handling e.g. local changes
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"git",
["pull"],
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) { if (exitCode !== 0) {
throw new Error("Failed to pull, please check the terminal output for more information."); throw new Error(
"Failed to pull, please check the terminal output for more information."
);
} }
// If the stack is not running, we don't need to restart it // If the stack is not running, we don't need to restart it
@ -497,9 +673,18 @@ export class Stack {
return exitCode; return exitCode;
} }
exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "up", "-d", "--remove-orphans"],
this.path
);
if (exitCode !== 0) { if (exitCode !== 0) {
throw new Error("Failed to restart, please check the terminal output for more information."); throw new Error(
"Failed to restart, please check the terminal output for more information."
);
} }
return exitCode; return exitCode;
} }
@ -553,8 +738,17 @@ export class Stack {
} }
async joinCombinedTerminal(socket: DockgeSocket) { async joinCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(socket.endpoint, this.name); const terminalName = getCombinedTerminalName(
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path); socket.endpoint,
this.name
);
const terminal = Terminal.getOrCreateTerminal(
this.server,
terminalName,
"docker",
["compose", "logs", "-f", "--tail", "100"],
this.path
);
terminal.enableKeepAlive = true; terminal.enableKeepAlive = true;
terminal.rows = COMBINED_TERMINAL_ROWS; terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS; terminal.cols = COMBINED_TERMINAL_COLS;
@ -563,19 +757,38 @@ export class Stack {
} }
async leaveCombinedTerminal(socket: DockgeSocket) { async leaveCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(socket.endpoint, this.name); const terminalName = getCombinedTerminalName(
socket.endpoint,
this.name
);
const terminal = Terminal.getTerminal(terminalName); const terminal = Terminal.getTerminal(terminalName);
if (terminal) { if (terminal) {
terminal.leave(socket); terminal.leave(socket);
} }
} }
async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) { async joinContainerTerminal(
const terminalName = getContainerExecTerminalName(socket.endpoint, this.name, serviceName, index); socket: DockgeSocket,
serviceName: string,
shell: string = "sh",
index: number = 0
) {
const terminalName = getContainerExecTerminalName(
socket.endpoint,
this.name,
serviceName,
index
);
let terminal = Terminal.getTerminal(terminalName); let terminal = Terminal.getTerminal(terminalName);
if (!terminal) { if (!terminal) {
terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path); terminal = new InteractiveTerminal(
this.server,
terminalName,
"docker",
["compose", "exec", serviceName, shell],
this.path
);
terminal.rows = TERMINAL_ROWS; terminal.rows = TERMINAL_ROWS;
log.debug("joinContainerTerminal", "Terminal created"); log.debug("joinContainerTerminal", "Terminal created");
} }
@ -588,10 +801,14 @@ export class Stack {
let statusList = new Map<string, number>(); let statusList = new Map<string, number>();
try { try {
let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { let res = await childProcessAsync.spawn(
cwd: this.path, "docker",
encoding: "utf-8", ["compose", "ps", "--format", "json"],
}); {
cwd: this.path,
encoding: "utf-8",
}
);
if (!res.stdout) { if (!res.stdout) {
return statusList; return statusList;
@ -607,8 +824,7 @@ export class Stack {
} else { } else {
statusList.set(obj.Service, obj.Health); statusList.set(obj.Service, obj.Health);
} }
} catch (e) { } catch (e) {}
}
} }
return statusList; return statusList;
@ -616,6 +832,5 @@ export class Stack {
log.error("getServiceStatusList", e); log.error("getServiceStatusList", e);
return statusList; return statusList;
} }
} }
} }

View file

@ -61,9 +61,9 @@ export default {
}, },
url() { url() {
if (this.stack.endpoint) { if (this.stack.endpoint) {
return `/compose/${this.stack.name}/${this.stack.endpoint}`; return `/compose/${encodeURIComponent(this.stack.name)}/${this.stack.endpoint}`;
} else { } else {
return `/compose/${this.stack.name}`; return `/compose/${encodeURIComponent(this.stack.name)}`;
} }
}, },
depthMargin() { depthMargin() {

View file

@ -473,9 +473,9 @@ export default {
url() { url() {
if (this.stack.endpoint) { if (this.stack.endpoint) {
return `/compose/${this.stack.name}/${this.stack.endpoint}`; return `/compose/${encodeURIComponent(this.stack.name)}/${this.stack.endpoint}`;
} else { } else {
return `/compose/${this.stack.name}`; return `/compose/${encodeURIComponent(this.stack.name)}`;
} }
}, },
}, },