stack.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import { DockgeServer } from "./dockge-server";
  2. import fs from "fs";
  3. import { log } from "./log";
  4. import yaml from "yaml";
  5. import { DockgeSocket, ValidationError } from "./util-server";
  6. import path from "path";
  7. import {
  8. COMBINED_TERMINAL_COLS,
  9. COMBINED_TERMINAL_ROWS,
  10. CREATED_FILE,
  11. CREATED_STACK,
  12. EXITED, getCombinedTerminalName,
  13. getComposeTerminalName, getContainerExecTerminalName,
  14. PROGRESS_TERMINAL_ROWS,
  15. RUNNING, TERMINAL_ROWS,
  16. UNKNOWN
  17. } from "./util-common";
  18. import { InteractiveTerminal, Terminal } from "./terminal";
  19. import childProcess from "child_process";
  20. export class Stack {
  21. name: string;
  22. protected _status: number = UNKNOWN;
  23. protected _composeYAML?: string;
  24. protected _configFilePath?: string;
  25. protected server: DockgeServer;
  26. protected combinedTerminal? : Terminal;
  27. protected static managedStackList: Map<string, Stack> = new Map();
  28. constructor(server : DockgeServer, name : string, composeYAML? : string) {
  29. this.name = name;
  30. this.server = server;
  31. this._composeYAML = composeYAML;
  32. }
  33. toJSON() : object {
  34. let obj = this.toSimpleJSON();
  35. return {
  36. ...obj,
  37. composeYAML: this.composeYAML,
  38. };
  39. }
  40. toSimpleJSON() : object {
  41. return {
  42. name: this.name,
  43. status: this._status,
  44. tags: [],
  45. isManagedByDockge: this.isManagedByDockge,
  46. };
  47. }
  48. /**
  49. * Get the status of the stack from `docker compose ps --format json`
  50. */
  51. ps() : object {
  52. let res = childProcess.execSync("docker compose ps --format json", {
  53. cwd: this.path
  54. });
  55. return JSON.parse(res.toString());
  56. }
  57. get isManagedByDockge() : boolean {
  58. return fs.existsSync(this.path) && fs.statSync(this.path).isDirectory();
  59. }
  60. get status() : number {
  61. return this._status;
  62. }
  63. validate() {
  64. // Check name, allows [a-z][0-9] _ - only
  65. if (!this.name.match(/^[a-z0-9_-]+$/)) {
  66. throw new ValidationError("Stack name can only contain [a-z][0-9] _ - only");
  67. }
  68. // Check YAML format
  69. yaml.parse(this.composeYAML);
  70. }
  71. get composeYAML() : string {
  72. if (this._composeYAML === undefined) {
  73. try {
  74. this._composeYAML = fs.readFileSync(path.join(this.path, "compose.yaml"), "utf-8");
  75. } catch (e) {
  76. this._composeYAML = "";
  77. }
  78. }
  79. return this._composeYAML;
  80. }
  81. get path() : string {
  82. return path.join(this.server.stacksDir, this.name);
  83. }
  84. get fullPath() : string {
  85. let dir = this.path;
  86. // Compose up via node-pty
  87. let fullPathDir;
  88. // if dir is relative, make it absolute
  89. if (!path.isAbsolute(dir)) {
  90. fullPathDir = path.join(process.cwd(), dir);
  91. } else {
  92. fullPathDir = dir;
  93. }
  94. return fullPathDir;
  95. }
  96. /**
  97. * Save the stack to the disk
  98. * @param isAdd
  99. */
  100. save(isAdd : boolean) {
  101. this.validate();
  102. let dir = this.path;
  103. // Check if the name is used if isAdd
  104. if (isAdd) {
  105. if (fs.existsSync(dir)) {
  106. throw new ValidationError("Stack name already exists");
  107. }
  108. // Create the stack folder
  109. fs.mkdirSync(dir);
  110. } else {
  111. if (!fs.existsSync(dir)) {
  112. throw new ValidationError("Stack not found");
  113. }
  114. }
  115. // Write or overwrite the compose.yaml
  116. fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML);
  117. }
  118. async deploy(socket? : DockgeSocket) : Promise<number> {
  119. const terminalName = getComposeTerminalName(this.name);
  120. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
  121. if (exitCode !== 0) {
  122. throw new Error("Failed to deploy, please check the terminal output for more information.");
  123. }
  124. return exitCode;
  125. }
  126. async delete(socket?: DockgeSocket) : Promise<number> {
  127. const terminalName = getComposeTerminalName(this.name);
  128. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans", "all" ], this.path);
  129. if (exitCode !== 0) {
  130. throw new Error("Failed to delete, please check the terminal output for more information.");
  131. }
  132. // Remove the stack folder
  133. fs.rmSync(this.path, {
  134. recursive: true,
  135. force: true
  136. });
  137. return exitCode;
  138. }
  139. static getStackList(server : DockgeServer, useCacheForManaged = false) : Map<string, Stack> {
  140. let stacksDir = server.stacksDir;
  141. let stackList : Map<string, Stack>;
  142. if (useCacheForManaged && this.managedStackList.size > 0) {
  143. stackList = this.managedStackList;
  144. } else {
  145. stackList = new Map<string, Stack>();
  146. // Scan the stacks directory, and get the stack list
  147. let filenameList = fs.readdirSync(stacksDir);
  148. for (let filename of filenameList) {
  149. try {
  150. // Check if it is a directory
  151. let stat = fs.statSync(path.join(stacksDir, filename));
  152. if (!stat.isDirectory()) {
  153. continue;
  154. }
  155. let stack = this.getStack(server, filename);
  156. stack._status = CREATED_FILE;
  157. stackList.set(filename, stack);
  158. } catch (e) {
  159. log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`);
  160. }
  161. }
  162. // Cache by copying
  163. this.managedStackList = new Map(stackList);
  164. }
  165. // Also get the list from `docker compose ls --all --format json`
  166. let res = childProcess.execSync("docker compose ls --all --format json");
  167. let composeList = JSON.parse(res.toString());
  168. for (let composeStack of composeList) {
  169. // Skip the dockge stack
  170. // TODO: Could be self managed?
  171. if (composeStack.Name === "dockge") {
  172. continue;
  173. }
  174. let stack = stackList.get(composeStack.Name);
  175. // This stack probably is not managed by Dockge, but we still want to show it
  176. if (!stack) {
  177. stack = new Stack(server, composeStack.Name);
  178. stackList.set(composeStack.Name, stack);
  179. }
  180. stack._status = this.statusConvert(composeStack.Status);
  181. stack._configFilePath = composeStack.ConfigFiles;
  182. }
  183. return stackList;
  184. }
  185. /**
  186. * Get the status list, it will be used to update the status of the stacks
  187. * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned
  188. */
  189. static getStatusList() : Map<string, number> {
  190. let statusList = new Map<string, number>();
  191. let res = childProcess.execSync("docker compose ls --all --format json");
  192. let composeList = JSON.parse(res.toString());
  193. for (let composeStack of composeList) {
  194. statusList.set(composeStack.Name, this.statusConvert(composeStack.Status));
  195. }
  196. return statusList;
  197. }
  198. /**
  199. * Convert the status string from `docker compose ls` to the status number
  200. * @param status
  201. */
  202. static statusConvert(status : string) : number {
  203. if (status.startsWith("created")) {
  204. return CREATED_STACK;
  205. } else if (status.startsWith("running")) {
  206. return RUNNING;
  207. } else if (status.startsWith("exited")) {
  208. return EXITED;
  209. } else {
  210. return UNKNOWN;
  211. }
  212. }
  213. static getStack(server: DockgeServer, stackName: string) : Stack {
  214. let dir = path.join(server.stacksDir, stackName);
  215. if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
  216. // Maybe it is a stack managed by docker compose directly
  217. let stackList = this.getStackList(server);
  218. let stack = stackList.get(stackName);
  219. if (stack) {
  220. return stack;
  221. } else {
  222. // Really not found
  223. throw new ValidationError("Stack not found");
  224. }
  225. }
  226. let stack = new Stack(server, stackName);
  227. stack._status = UNKNOWN;
  228. stack._configFilePath = path.resolve(dir);
  229. return stack;
  230. }
  231. async start(socket: DockgeSocket) {
  232. const terminalName = getComposeTerminalName(this.name);
  233. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
  234. if (exitCode !== 0) {
  235. throw new Error("Failed to start, please check the terminal output for more information.");
  236. }
  237. return exitCode;
  238. }
  239. async stop(socket: DockgeSocket) : Promise<number> {
  240. const terminalName = getComposeTerminalName(this.name);
  241. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path);
  242. if (exitCode !== 0) {
  243. throw new Error("Failed to stop, please check the terminal output for more information.");
  244. }
  245. return exitCode;
  246. }
  247. async restart(socket: DockgeSocket) : Promise<number> {
  248. const terminalName = getComposeTerminalName(this.name);
  249. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path);
  250. if (exitCode !== 0) {
  251. throw new Error("Failed to restart, please check the terminal output for more information.");
  252. }
  253. return exitCode;
  254. }
  255. async update(socket: DockgeSocket) {
  256. const terminalName = getComposeTerminalName(this.name);
  257. let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path);
  258. if (exitCode !== 0) {
  259. throw new Error("Failed to pull, please check the terminal output for more information.");
  260. }
  261. exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path);
  262. if (exitCode !== 0) {
  263. throw new Error("Failed to restart, please check the terminal output for more information.");
  264. }
  265. return exitCode;
  266. }
  267. async joinCombinedTerminal(socket: DockgeSocket) {
  268. const terminalName = getCombinedTerminalName(this.name);
  269. const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path);
  270. terminal.rows = COMBINED_TERMINAL_ROWS;
  271. terminal.cols = COMBINED_TERMINAL_COLS;
  272. terminal.join(socket);
  273. terminal.start();
  274. }
  275. async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) {
  276. const terminalName = getContainerExecTerminalName(this.name, serviceName, index);
  277. let terminal = Terminal.getTerminal(terminalName);
  278. if (!terminal) {
  279. terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path);
  280. terminal.rows = TERMINAL_ROWS;
  281. log.debug("joinContainerTerminal", "Terminal created");
  282. }
  283. terminal.join(socket);
  284. terminal.start();
  285. }
  286. async getServiceStatusList() {
  287. let statusList = new Map<string, number>();
  288. let res = childProcess.execSync("docker compose ps --format json", {
  289. cwd: this.path,
  290. });
  291. let lines = res.toString().split("\n");
  292. for (let line of lines) {
  293. try {
  294. let obj = JSON.parse(line);
  295. statusList.set(obj.Service, obj.State);
  296. } catch (e) {
  297. }
  298. }
  299. return statusList;
  300. }
  301. }