dockge/backend/stack.ts
2024-11-17 11:35:49 +00:00

836 lines
26 KiB
TypeScript

import { DockgeServer } from "./dockge-server";
import fs, { promises as fsAsync } from "fs";
import { log } from "./log";
import yaml from "yaml";
import { DockgeSocket, fileExists, ValidationError } from "./util-server";
import path from "path";
import {
acceptedComposeFileNames,
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
CREATED_FILE,
CREATED_STACK,
EXITED,
getCombinedTerminalName,
getComposeTerminalName,
getContainerExecTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING,
TERMINAL_ROWS,
UNKNOWN,
} from "../common/util-common";
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 {
name: string;
protected _status: number = UNKNOWN;
protected _composeYAML?: string;
protected _composeENV?: string;
protected _configFilePath?: string;
protected _composeFileName: string = "compose.yaml";
protected server: DockgeServer;
protected combinedTerminal?: Terminal;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(
server: DockgeServer,
name: string,
composeYAML?: string,
composeENV?: string,
skipFSOperations = false
) {
this.name = name;
this.server = server;
this._composeYAML = composeYAML;
this._composeENV = composeENV;
if (!skipFSOperations) {
// Check if compose file name is different from compose.yaml
for (const filename of acceptedComposeFileNames) {
if (fs.existsSync(path.join(this.path, filename))) {
this._composeFileName = filename;
break;
}
}
}
}
async toJSON(endpoint: string): Promise<object> {
// Since we have multiple agents now, embed primary hostname in the stack object too.
let primaryHostname = await Settings.get("primaryHostname");
if (!primaryHostname) {
if (!endpoint) {
primaryHostname = "localhost";
} else {
// Use the endpoint as the primary hostname
try {
primaryHostname = new URL("https://" + endpoint).hostname;
} catch (e) {
// Just in case if the endpoint is in a incorrect format
primaryHostname = "localhost";
}
}
}
let obj = this.toSimpleJSON(endpoint);
return {
...obj,
composeYAML: this.composeYAML,
composeENV: this.composeENV,
primaryHostname,
};
}
toSimpleJSON(endpoint: string): object {
return {
name: this.name,
status: this._status,
tags: [],
isManagedByDockge: this.isManagedByDockge,
isGitRepo: this.isGitRepo,
gitUrl: this.gitUrl,
branch: this.branch,
webhook: this.webhook,
composeFileName: this._composeFileName,
endpoint,
};
}
/**
* Get the status of the stack from `docker compose ps --format json`
*/
async ps(): Promise<object> {
let res = await childProcessAsync.spawn(
"docker",
["compose", "ps", "--format", "json"],
{
cwd: this.path,
encoding: "utf-8",
}
);
if (!res.stdout) {
return {};
}
return JSON.parse(res.stdout.toString());
}
get isManagedByDockge(): boolean {
return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
}
get isGitRepo(): boolean {
try {
execSync("git rev-parse --is-inside-work-tree", { cwd: this.path });
return true;
} catch (error) {
return false;
}
}
get gitUrl() : string {
if (this.isGitRepo) {
try {
let stdout = execSync("git config --get remote.origin.url", { cwd: this.path });
return stdout.toString().trim();
} catch (error) {
return "";
}
}
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/${encodeURIComponent(this.name)}`;
} else {
return `http://localhost:${this.server.config.port}/webhook/update/${encodeURIComponent(this.name)}`;
}
}
get status(): number {
return this._status;
}
validate() {
// Check name, allows [a-z][0-9] _ - only
if (!this.name.match(/^[a-z0-9_-]+$/)) {
throw new ValidationError(
"Stack name can only contain [a-z][0-9] _ - only"
);
}
// Check YAML format
yaml.parse(this.composeYAML);
let lines = this.composeENV.split("\n");
// Check if the .env is able to pass docker-compose
// Prevent "setenv: The parameter is incorrect"
// It only happens when there is one line and it doesn't contain "="
if (
lines.length === 1 &&
!lines[0].includes("=") &&
lines[0].length > 0
) {
throw new ValidationError("Invalid .env format");
}
}
get composeYAML(): string {
if (this._composeYAML === undefined) {
try {
this._composeYAML = fs.readFileSync(
path.join(this.path, this._composeFileName),
"utf-8"
);
} catch (e) {
this._composeYAML = "";
}
}
return this._composeYAML;
}
get composeENV(): string {
if (this._composeENV === undefined) {
try {
this._composeENV = fs.readFileSync(
path.join(this.path, ".env"),
"utf-8"
);
} catch (e) {
this._composeENV = "";
}
}
return this._composeENV;
}
get path(): string {
return path.join(this.server.stacksDir, this.name);
}
get fullPath(): string {
let dir = this.path;
// Compose up via node-pty
let fullPathDir;
// if dir is relative, make it absolute
if (!path.isAbsolute(dir)) {
fullPathDir = path.join(process.cwd(), dir);
} else {
fullPathDir = dir;
}
return fullPathDir;
}
/**
* Save the stack to the disk
* @param isAdd
*/
async save(isAdd: boolean) {
this.validate();
let dir = this.path;
// Check if the name is used if isAdd
if (isAdd) {
if (await fileExists(dir)) {
throw new ValidationError("Stack name already exists");
}
// Create the stack folder
await fsAsync.mkdir(dir);
} else {
if (!(await fileExists(dir))) {
throw new ValidationError("Stack not found");
}
}
// Write or overwrite the compose.yaml
await fsAsync.writeFile(
path.join(dir, this._composeFileName),
this.composeYAML
);
const envPath = path.join(dir, ".env");
// Write or overwrite the .env
// If .env is not existing and the composeENV is empty, we don't need to write it
if ((await fileExists(envPath)) || this.composeENV.trim() !== "") {
await fsAsync.writeFile(envPath, this.composeENV);
}
}
async deploy(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "up", "-d", "--remove-orphans"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to deploy, please check the terminal output for more information."
);
}
return exitCode;
}
async delete(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "down", "--remove-orphans"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to delete, please check the terminal output for more information."
);
}
// Remove the stack folder
await fsAsync.rm(this.path, {
recursive: true,
force: true,
});
return exitCode;
}
async updateStatus() {
let statusList = await Stack.getStatusList();
let status = statusList.get(this.name);
if (status) {
this._status = status;
} else {
this._status = UNKNOWN;
}
}
/**
* Checks if a compose file exists in the specified directory.
* @async
* @static
* @param {string} dir - The directory to search.
* @returns {Promise<boolean>} A promise that resolves to a boolean indicating whether any compose file exists.
*/
static async composeFileExists(dir: string): Promise<boolean> {
// Check if any compose file exists
for (const filename of acceptedComposeFileNames) {
let composeFile = path.join(dir, filename);
if (await fileExists(composeFile)) {
return true;
}
}
return false;
}
static async getStackList(
server: DockgeServer,
useCacheForManaged = false,
depth = 0,
dir = ""
): Promise<Map<string, Stack>> {
let stacksDir = server.stacksDir;
let currentDir = dir ? path.join(stacksDir, dir) : stacksDir;
let stackList: Map<string, Stack>;
// Use cached stack list?
if (useCacheForManaged && this.managedStackList.size > 0) {
stackList = this.managedStackList;
} else {
stackList = new Map<string, Stack>();
// Scan the stacks directory, and get the stack list
let filenameList = await fsAsync.readdir(currentDir);
for (let filename of filenameList) {
try {
// Check if it is a directory
let stat = await fsAsync.stat(
path.join(currentDir, filename)
);
if (!stat.isDirectory()) {
continue;
}
// If no compose file exists, skip it
if (
!(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,
path.join(dir, filename)
);
stack._status = CREATED_FILE;
stackList.set(path.join(dir, filename), stack);
} catch (e) {
if (e instanceof Error) {
log.warn(
"getStackList",
`Failed to get stack ${filename}, error: ${e.message}`
);
}
}
}
// Cache by copying
this.managedStackList = new Map(stackList);
}
// Get status from docker compose ls
let res = await childProcessAsync.spawn(
"docker",
["compose", "ls", "--all", "--format", "json"],
{
encoding: "utf-8",
}
);
if (!res.stdout) {
return stackList;
}
let composeList = JSON.parse(res.stdout.toString());
for (let composeStack of composeList) {
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
if (!stack) {
// Skip the dockge stack if it is not managed by Dockge
if (composeStack.Name === "dockge") {
continue;
}
stack = new Stack(server, stackName);
stackList.set(stackName, stack);
}
stack._status = this.statusConvert(composeStack.Status);
stack._configFilePath = composeStack.ConfigFiles;
}
return stackList;
}
/**
* 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
*/
static async getStatusList(): Promise<Map<string, number>> {
let statusList = new Map<string, number>();
let res = await childProcessAsync.spawn(
"docker",
["compose", "ls", "--all", "--format", "json"],
{
encoding: "utf-8",
}
);
if (!res.stdout) {
return statusList;
}
let composeList = JSON.parse(res.stdout.toString());
for (let composeStack of composeList) {
statusList.set(
composeStack.Name,
this.statusConvert(composeStack.Status)
);
}
return statusList;
}
/**
* Convert the status string from `docker compose ls` to the status number
* Input Example: "exited(1), running(1)"
* @param status
*/
static statusConvert(status: string): number {
if (status.startsWith("created")) {
return CREATED_STACK;
} else if (status.includes("exited")) {
// If one of the service is exited, we consider the stack is exited
return EXITED;
} else if (status.startsWith("running")) {
// If there is no exited services, there should be only running services
return RUNNING;
} else {
return UNKNOWN;
}
}
static async getStack(
server: DockgeServer,
stackName: string,
skipFSOperations = false
): Promise<Stack> {
let dir = path.join(server.stacksDir, stackName);
if (!skipFSOperations) {
if (
!(await fileExists(dir)) ||
!(await fsAsync.stat(dir)).isDirectory()
) {
// Maybe it is a stack managed by docker compose directly
let stackList = await this.getStackList(server, true);
let stack = stackList.get(stackName);
if (stack) {
return stack;
} else {
// Really not found
throw new ValidationError("Stack not found");
}
}
} else {
//log.debug("getStack", "Skip FS operations");
}
let stack: Stack;
if (!skipFSOperations) {
stack = new Stack(server, stackName);
} else {
stack = new Stack(server, stackName, undefined, undefined, true);
}
stack._status = UNKNOWN;
stack._configFilePath = path.resolve(dir);
return stack;
}
async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "up", "-d", "--remove-orphans"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to start, please check the terminal output for more information."
);
}
return exitCode;
}
async stop(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "stop"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to stop, please check the terminal output for more information."
);
}
return exitCode;
}
async restart(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "restart"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to restart, please check the terminal output for more information."
);
}
return exitCode;
}
async down(socket: DockgeSocket): Promise<number> {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
let exitCode = await Terminal.exec(
this.server,
socket,
terminalName,
"docker",
["compose", "down"],
this.path
);
if (exitCode !== 0) {
throw new Error(
"Failed to down, please check the terminal output for more information."
);
}
return exitCode;
}
async update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(socket.endpoint, this.name);
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) {
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
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;
}
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
);
terminal.enableKeepAlive = true;
terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS;
terminal.join(socket);
terminal.start();
}
async leaveCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(
socket.endpoint,
this.name
);
const terminal = Terminal.getTerminal(terminalName);
if (terminal) {
terminal.leave(socket);
}
}
async joinContainerTerminal(
socket: DockgeSocket,
serviceName: string,
shell: string = "sh",
index: number = 0
) {
const terminalName = getContainerExecTerminalName(
socket.endpoint,
this.name,
serviceName,
index
);
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new InteractiveTerminal(
this.server,
terminalName,
"docker",
["compose", "exec", serviceName, shell],
this.path
);
terminal.rows = TERMINAL_ROWS;
log.debug("joinContainerTerminal", "Terminal created");
}
terminal.join(socket);
terminal.start();
}
async getServiceStatusList() {
let statusList = new Map<string, number>();
try {
let res = await childProcessAsync.spawn(
"docker",
["compose", "ps", "--format", "json"],
{
cwd: this.path,
encoding: "utf-8",
}
);
if (!res.stdout) {
return statusList;
}
let lines = res.stdout?.toString().split("\n");
for (let line of lines) {
try {
let obj = JSON.parse(line);
if (obj.Health === "") {
statusList.set(obj.Service, obj.State);
} else {
statusList.set(obj.Service, obj.Health);
}
} catch (e) {}
}
return statusList;
} catch (e) {
log.error("getServiceStatusList", e);
return statusList;
}
}
}