This commit is contained in:
Louis Lam
2023-11-06 01:18:02 +08:00
parent e67d08b7b3
commit 314630724b
40 changed files with 2873 additions and 351 deletions

View File

@@ -424,14 +424,27 @@ export class DockgeServer {
}
sendStackList(useCache = false) {
let stackList = Stack.getStackList(this, useCache);
let roomList = this.io.sockets.adapter.rooms.keys();
let map : Map<string, object> | undefined;
for (let room of roomList) {
// Check if the room is a number (user id)
if (Number(room)) {
// Get the list only if there is a room
if (!map) {
map = new Map();
let stackList = Stack.getStackList(this, useCache);
for (let [ stackName, stack ] of stackList) {
map.set(stackName, stack.toSimpleJSON());
}
}
log.debug("server", "Send stack list to room " + room);
this.io.to(room).emit("stackList", {
ok: true,
stackList: Object.fromEntries(stackList),
stackList: Object.fromEntries(map),
});
}
}

View File

@@ -10,6 +10,13 @@ export class MainRouter extends Router {
res.send(server.indexHTML);
});
// Robots.txt
router.get("/robots.txt", async (_request, response) => {
let txt = "User-agent: *\nDisallow: /";
response.setHeader("Content-Type", "text/plain");
response.send(txt);
});
return router;
}

View File

@@ -14,6 +14,7 @@ export class DockerSocketHandler extends SocketHandler {
server.sendStackList();
callback({
ok: true,
msg: "Deployed",
});
} catch (e) {
callbackError(e, callback);
@@ -69,6 +70,9 @@ export class DockerSocketHandler extends SocketHandler {
}
const stack = Stack.getStack(server, stackName);
stack.startCombinedTerminal(socket);
callback({
ok: true,
stack: stack.toJSON(),
@@ -77,6 +81,107 @@ export class DockerSocketHandler extends SocketHandler {
callbackError(e, callback);
}
});
// requestStackList
socket.on("requestStackList", async (callback) => {
try {
checkLogin(socket);
server.sendStackList();
callback({
ok: true,
msg: "Updated"
});
} catch (e) {
callbackError(e, callback);
}
});
// startStack
socket.on("startStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.start(socket);
callback({
ok: true,
msg: "Started"
});
server.sendStackList();
stack.startCombinedTerminal(socket);
} catch (e) {
callbackError(e, callback);
}
});
// stopStack
socket.on("stopStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.stop(socket);
callback({
ok: true,
msg: "Stopped"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// restartStack
socket.on("restartStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.restart(socket);
callback({
ok: true,
msg: "Restarted"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
// updateStack
socket.on("updateStack", async (stackName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string");
}
const stack = Stack.getStack(server, stackName);
await stack.update(socket);
callback({
ok: true,
msg: "Updated"
});
server.sendStackList();
} catch (e) {
callbackError(e, callback);
}
});
}
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {
@@ -95,5 +200,6 @@ export class DockerSocketHandler extends SocketHandler {
stack.save(isAdd);
return stack;
}
}

View File

@@ -8,64 +8,49 @@ import fs from "fs";
import {
allowedCommandList,
allowedRawKeys,
getComposeTerminalName,
getComposeTerminalName, getContainerExecTerminalName,
isDev,
PROGRESS_TERMINAL_ROWS
} from "../util-common";
import { MainTerminal, Terminal } from "../terminal";
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
export class TerminalSocketHandler extends SocketHandler {
create(socket : DockgeSocket, server : DockgeServer) {
socket.on("terminalInputRaw", async (key : unknown) => {
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback) => {
try {
checkLogin(socket);
if (typeof(key) !== "string") {
throw new Error("Key must be a string.");
if (typeof(terminalName) !== "string") {
throw new Error("Terminal name must be a string.");
}
if (allowedRawKeys.includes(key)) {
server.terminal.write(key);
}
} catch (e) {
}
});
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback : unknown) => {
try {
checkLogin(socket);
if (typeof(cmd) !== "string") {
throw new Error("Command must be a string.");
}
// Check if the command is allowed
const cmdParts = cmd.split(" ");
const executable = cmdParts[0].trim();
log.debug("console", "Executable: " + executable);
log.debug("console", "Executable length: " + executable.length);
if (!allowedCommandList.includes(executable)) {
throw new Error("Command not allowed.");
let terminal = Terminal.getTerminal(terminalName);
if (terminal instanceof InteractiveTerminal) {
terminal.write(cmd);
} else {
throw new Error("Terminal not found or it is not a Interactive Terminal.");
}
server.terminal.write(cmd);
} catch (e) {
if (typeof(errorCallback) === "function") {
errorCallback({
ok: false,
msg: e.message,
});
}
errorCallback({
ok: false,
msg: e.message,
});
}
});
// Create Terminal
// Main Terminal
socket.on("mainTerminal", async (terminalName : unknown, callback) => {
try {
checkLogin(socket);
// TODO: Reset the name here, force one main terminal for now
terminalName = "console";
if (typeof(terminalName) !== "string") {
throw new ValidationError("Terminal name must be a string.");
}
@@ -91,7 +76,40 @@ export class TerminalSocketHandler extends SocketHandler {
}
});
// Join Terminal
// Interactive Terminal for containers
socket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, callback) => {
try {
checkLogin(socket);
if (typeof(stackName) !== "string") {
throw new ValidationError("Stack name must be a string.");
}
if (typeof(serviceName) !== "string") {
throw new ValidationError("Service name must be a string.");
}
const terminalName = getContainerExecTerminalName(stackName, serviceName, 0);
let terminal = Terminal.getTerminal(terminalName);
if (!terminal) {
terminal = new InteractiveTerminal(server, terminalName);
terminal.rows = 50;
log.debug("deployStack", "Terminal created");
}
terminal.join(socket);
terminal.start();
callback({
ok: true,
});
} catch (e) {
callbackError(e, callback);
}
});
// Join Output Terminal
socket.on("terminalJoin", async (terminalName : unknown, callback) => {
if (typeof(callback) !== "function") {
log.debug("console", "Callback is not a function.");
@@ -124,18 +142,9 @@ export class TerminalSocketHandler extends SocketHandler {
});
// Resize Terminal
// TODO: Resize Terminal
socket.on("terminalResize", async (rows : unknown) => {
try {
checkLogin(socket);
if (typeof(rows) !== "number") {
throw new Error("Rows must be a number.");
}
log.debug("console", "Resize terminal to " + rows + " rows.");
server.terminal.resize(rows);
} catch (e) {
}
});
}
}

View File

@@ -5,9 +5,11 @@ import yaml from "yaml";
import { DockgeSocket, ValidationError } from "./util-server";
import path from "path";
import {
COMBINED_TERMINAL_COLS,
COMBINED_TERMINAL_ROWS,
CREATED_FILE,
CREATED_STACK,
EXITED,
EXITED, getCombinedTerminalName,
getComposeTerminalName,
PROGRESS_TERMINAL_ROWS,
RUNNING,
@@ -24,6 +26,8 @@ export class Stack {
protected _configFilePath?: string;
protected server: DockgeServer;
protected combinedTerminal? : Terminal;
protected static managedStackList: Map<string, Stack> = new Map();
constructor(server : DockgeServer, name : string, composeYAML? : string) {
@@ -37,7 +41,6 @@ export class Stack {
return {
...obj,
composeYAML: this.composeYAML,
isManagedByDockge: this.isManagedByDockge,
};
}
@@ -46,9 +49,20 @@ export class Stack {
name: this.name,
status: this._status,
tags: [],
isManagedByDockge: this.isManagedByDockge,
};
}
/**
* Get the status of the stack from `docker compose ps --format json`
*/
ps() : object {
let res = childProcess.execSync("docker compose ps --format json", {
cwd: this.path
});
return JSON.parse(res.toString());
}
get isManagedByDockge() : boolean {
if (this._configFilePath) {
return this._configFilePath.startsWith(this.server.stackDirFullPath) && fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
@@ -128,70 +142,29 @@ export class Stack {
fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
}
deploy(socket? : DockgeSocket) : Promise<number> {
async deploy(socket? : DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
log.debug("deployStack", "Terminal name: " + terminalName);
const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "up", "-d" ], this.path);
log.debug("deployStack", "Terminal created");
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
log.debug("deployStack", "Terminal joined");
} else {
log.debug("deployStack", "No socket, not joining");
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 new Promise((resolve, reject) => {
terminal.onExit((exitCode : number) => {
if (exitCode === 0) {
resolve(exitCode);
} else {
reject(new Error("Failed to deploy, please check the terminal output for more information."));
}
});
terminal.start();
});
return exitCode;
}
delete(socket?: DockgeSocket) : Promise<number> {
// Docker compose down
async delete(socket?: DockgeSocket) : Promise<number> {
const terminalName = getComposeTerminalName(this.name);
log.debug("deleteStack", "Terminal name: " + terminalName);
const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "down" ], this.path);
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
log.debug("deployStack", "Terminal joined");
} else {
log.debug("deployStack", "No socket, not joining");
let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker-compose", [ "down", "--remove-orphans", "--rmi", "all" ], this.path);
if (exitCode !== 0) {
throw new Error("Failed to delete, please check the terminal output for more information.");
}
return new Promise((resolve, reject) => {
terminal.onExit((exitCode : number) => {
if (exitCode === 0) {
// Remove the stack folder
try {
fs.rmSync(this.path, {
recursive: true,
force: true
});
resolve(exitCode);
} catch (e) {
reject(e);
}
} else {
reject(new Error("Failed to delete, please check the terminal output for more information."));
}
});
terminal.start();
// Remove the stack folder
fs.rmSync(this.path, {
recursive: true,
force: true
});
return exitCode;
}
static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
@@ -257,6 +230,10 @@ export class Stack {
return statusList;
}
/**
* Convert the status string from `docker compose ls` to the status number
* @param status
*/
static statusConvert(status : string) : number {
if (status.startsWith("created")) {
return CREATED_STACK;
@@ -290,4 +267,53 @@ export class Stack {
stack._configFilePath = path.resolve(dir);
return stack;
}
async start(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(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(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(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 update(socket: DockgeSocket) {
const terminalName = getComposeTerminalName(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.");
}
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 startCombinedTerminal(socket: DockgeSocket) {
const terminalName = getCombinedTerminalName(this.name);
const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker-compose", [ "logs", "-f" ], this.path);
terminal.rows = COMBINED_TERMINAL_ROWS;
terminal.cols = COMBINED_TERMINAL_COLS;
terminal.join(socket);
terminal.start();
}
}

View File

@@ -3,7 +3,14 @@ import * as os from "node:os";
import * as pty from "@homebridge/node-pty-prebuilt-multiarch";
import { LimitQueue } from "./utils/limit-queue";
import { DockgeSocket } from "./util-server";
import { getCryptoRandomInt, TERMINAL_COLS, TERMINAL_ROWS } from "./util-common";
import {
allowedCommandList, allowedRawKeys,
getComposeTerminalName,
getCryptoRandomInt,
PROGRESS_TERMINAL_ROWS,
TERMINAL_COLS,
TERMINAL_ROWS
} from "./util-common";
import { sync as commandExistsSync } from "command-exists";
import { log } from "./log";
@@ -25,6 +32,7 @@ export class Terminal {
protected callback? : (exitCode : number) => void;
protected _rows : number = TERMINAL_ROWS;
protected _cols : number = TERMINAL_COLS;
constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) {
this.server = server;
@@ -43,7 +51,16 @@ export class Terminal {
set rows(rows : number) {
this._rows = rows;
this.ptyProcess?.resize(TERMINAL_COLS, rows);
this.ptyProcess?.resize(this.cols, this.rows);
}
get cols() {
return this._cols;
}
set cols(cols : number) {
this._cols = cols;
this.ptyProcess?.resize(this.cols, this.rows);
}
public start() {
@@ -119,6 +136,30 @@ export class Terminal {
public static getTerminal(name : string) : Terminal | undefined {
return Terminal.terminalMap.get(name);
}
public static getOrCreateTerminal(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) : Terminal {
let terminal = Terminal.getTerminal(name);
if (!terminal) {
terminal = new Terminal(server, name, file, args, cwd);
}
return terminal;
}
public static exec(server : DockgeServer, socket : DockgeSocket | undefined, terminalName : string, file : string, args : string | string[], cwd : string) : Promise<number> {
const terminal = new Terminal(server, terminalName, file, args, cwd);
terminal.rows = PROGRESS_TERMINAL_ROWS;
if (socket) {
terminal.join(socket);
}
return new Promise((resolve) => {
terminal.onExit((exitCode : number) => {
resolve(exitCode);
});
terminal.start();
});
}
}
/**
@@ -140,7 +181,7 @@ export class InteractiveTerminal extends Terminal {
* User interactive terminal that use bash or powershell with limited commands such as docker, ls, cd, dir
*/
export class MainTerminal extends InteractiveTerminal {
constructor(server : DockgeServer, name : string, cwd : string = "./") {
constructor(server : DockgeServer, name : string) {
let shell;
if (os.platform() === "win32") {
@@ -152,6 +193,25 @@ export class MainTerminal extends InteractiveTerminal {
} else {
shell = "bash";
}
super(server, name, shell, [], cwd);
super(server, name, shell, [], server.stacksDir);
}
public write(input : string) {
// For like Ctrl + C
if (allowedRawKeys.includes(input)) {
super.write(input);
return;
}
// Check if the command is allowed
const cmdParts = input.split(" ");
const executable = cmdParts[0].trim();
log.debug("console", "Executable: " + executable);
log.debug("console", "Executable length: " + executable.length);
if (!allowedCommandList.includes(executable)) {
throw new Error("Command not allowed.");
}
super.write(input);
}
}

View File

@@ -11,6 +11,8 @@ dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
import { parseDocument, Document } from "yaml";
let randomBytes : (numBytes: number) => Uint8Array;
if (typeof window !== "undefined" && window.crypto) {
@@ -50,13 +52,13 @@ export function statusName(status : number) : string {
export function statusNameShort(status : number) : string {
switch (status) {
case CREATED_FILE:
return "draft";
return "inactive";
case CREATED_STACK:
return "inactive";
case RUNNING:
return "active";
case EXITED:
return "inactive";
return "exited";
default:
return "?";
}
@@ -67,7 +69,7 @@ export function statusColor(status : number) : string {
case CREATED_FILE:
return "dark";
case CREATED_STACK:
return "danger";
return "dark";
case RUNNING:
return "primary";
case EXITED:
@@ -78,10 +80,13 @@ export function statusColor(status : number) : string {
}
export const isDev = process.env.NODE_ENV === "development";
export const TERMINAL_COLS = 80;
export const TERMINAL_COLS = 105;
export const TERMINAL_ROWS = 10;
export const PROGRESS_TERMINAL_ROWS = 8;
export const COMBINED_TERMINAL_COLS = 50;
export const COMBINED_TERMINAL_ROWS = 15;
export const ERROR_TYPE_VALIDATION = 1;
export const allowedCommandList : string[] = [
@@ -182,11 +187,68 @@ export function getComposeTerminalName(stack : string) {
return "compose-" + stack;
}
export function getCombinedTerminalName(stack : string) {
return "combined-" + stack;
}
export function getContainerTerminalName(container : string) {
return "container-" + container;
}
export function getContainerExecTerminalName(container : string, index : number) {
export function getContainerExecTerminalName(stackName : string, container : string, index : number) {
return "container-exec-" + container + "-" + index;
}
export function copyYAMLComments(doc : Document, src : Document) {
doc.comment = src.comment;
doc.commentBefore = src.commentBefore;
if (doc && doc.contents && src && src.contents) {
// @ts-ignore
copyYAMLCommentsItems(doc.contents.items, src.contents.items);
}
}
/**
* Copy yaml comments from srcItems to items
* Typescript is super annoying here, so I have to use any here
* TODO: Since comments are belong to the array index, the comments will be lost if the order of the items is changed or removed or added.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function copyYAMLCommentsItems(items : any, srcItems : any) {
if (!items || !srcItems) {
return;
}
for (let i = 0; i < items.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const item : any = items[i];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const srcItem : any = srcItems[i];
if (!srcItem) {
continue;
}
if (item.key && srcItem.key) {
item.key.comment = srcItem.key.comment;
item.key.commentBefore = srcItem.key.commentBefore;
}
if (srcItem.comment) {
item.comment = srcItem.comment;
}
if (item.value && srcItem.value) {
if (typeof item.value === "object" && typeof srcItem.value === "object") {
item.value.comment = srcItem.value.comment;
item.value.commentBefore = srcItem.value.commentBefore;
if (item.value.items && srcItem.value.items) {
copyYAMLCommentsItems(item.value.items, srcItem.value.items);
}
}
}
}
}