feat(multi-server): add actions to the server

This commit is contained in:
Mauricio Siu
2024-09-21 00:06:41 -06:00
parent 0b22b694e6
commit 497d45129c
15 changed files with 684 additions and 134 deletions

View File

@@ -55,9 +55,10 @@ export const dockerRouter = createTRPCRouter({
.input(
z.object({
appName: z.string().min(1),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getContainersByAppLabel(input.appName);
return await getContainersByAppLabel(input.appName, input.serverId);
}),
});

View File

@@ -6,6 +6,7 @@ import {
apiReadStatsLogs,
apiReadTraefikConfig,
apiSaveSSHKey,
apiStorage,
apiTraefikConfig,
apiUpdateDockerCleanup,
} from "@/server/db/schema";
@@ -20,11 +21,13 @@ import {
cleanUpUnusedVolumes,
prepareEnvironmentVariables,
startService,
startServiceRemote,
stopService,
stopServiceRemote,
} from "@/server/utils/docker/utils";
import { recreateDirectory } from "@/server/utils/filesystem/directory";
import { sendDockerCleanupNotifications } from "@/server/utils/notifications/docker-cleanup";
import { execAsync } from "@/server/utils/process/execAsync";
import { execAsync, execAsyncRemote } from "@/server/utils/process/execAsync";
import { spawnAsync } from "@/server/utils/process/spawnAsync";
import {
readConfig,
@@ -63,16 +66,23 @@ export const settingsRouter = createTRPCRouter({
await execAsync(`docker service update --force ${stdout.trim()}`);
return true;
}),
reloadTraefik: adminProcedure.mutation(async () => {
try {
await stopService("dokploy-traefik");
await startService("dokploy-traefik");
} catch (err) {
console.error(err);
}
reloadTraefik: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
try {
if (input?.serverId) {
await stopServiceRemote(input.serverId, "dokploy-traefik");
await startServiceRemote(input.serverId, "dokploy-traefik");
} else {
await stopService("dokploy-traefik");
await startService("dokploy-traefik");
}
} catch (err) {
console.error(err);
}
return true;
}),
return true;
}),
toggleDashboard: adminProcedure
.input(apiEnableDashboard)
.mutation(async ({ input }) => {
@@ -82,31 +92,42 @@ export const settingsRouter = createTRPCRouter({
return true;
}),
cleanUnusedImages: adminProcedure.mutation(async () => {
await cleanUpUnusedImages();
return true;
}),
cleanUnusedVolumes: adminProcedure.mutation(async () => {
await cleanUpUnusedVolumes();
return true;
}),
cleanStoppedContainers: adminProcedure.mutation(async () => {
await cleanStoppedContainers();
return true;
}),
cleanDockerBuilder: adminProcedure.mutation(async () => {
await cleanUpDockerBuilder();
}),
cleanDockerPrune: adminProcedure.mutation(async () => {
await cleanUpSystemPrune();
await cleanUpDockerBuilder();
cleanUnusedImages: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
return true;
}),
cleanUnusedVolumes: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpUnusedVolumes(input?.serverId);
return true;
}),
cleanStoppedContainers: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanStoppedContainers(input?.serverId);
return true;
}),
cleanDockerBuilder: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpDockerBuilder(input?.serverId);
}),
cleanDockerPrune: adminProcedure
.input(apiStorage)
.mutation(async ({ input }) => {
await cleanUpSystemPrune(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
return true;
}),
cleanAll: adminProcedure.mutation(async () => {
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
return true;
}),
cleanAll: adminProcedure.input(apiStorage).mutation(async ({ input }) => {
await cleanUpUnusedImages(input?.serverId);
await cleanStoppedContainers(input?.serverId);
await cleanUpDockerBuilder(input?.serverId);
await cleanUpSystemPrune(input?.serverId);
return true;
}),
@@ -230,18 +251,20 @@ export const settingsRouter = createTRPCRouter({
getDokployVersion: adminProcedure.query(() => {
return getDokployVersion();
}),
readDirectories: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.rol === "user") {
const canAccess = await canAccessToTraefikFiles(ctx.user.authId);
readDirectories: protectedProcedure
.input(apiStorage)
.query(async ({ ctx, input }) => {
if (ctx.user.rol === "user") {
const canAccess = await canAccessToTraefikFiles(ctx.user.authId);
if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
if (!canAccess) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
}
const { MAIN_TRAEFIK_PATH } = paths();
const result = readDirectory(MAIN_TRAEFIK_PATH);
return result || [];
}),
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
}),
updateTraefikFile: protectedProcedure
.input(apiModifyTraefikConfig)
@@ -253,7 +276,11 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
writeTraefikConfigInPath(input.path, input.traefikConfig);
await writeTraefikConfigInPath(
input.path,
input.traefikConfig,
input?.serverId,
);
return true;
}),
@@ -267,7 +294,7 @@ export const settingsRouter = createTRPCRouter({
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return readConfigInPath(input.path);
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
const admin = await findAdmin();
@@ -324,16 +351,20 @@ export const settingsRouter = createTRPCRouter({
return openApiDocument;
},
),
readTraefikEnv: adminProcedure.query(async () => {
const { stdout } = await execAsync(
"docker service inspect --format='{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' dokploy-traefik",
);
readTraefikEnv: adminProcedure.input(apiStorage).query(async ({ input }) => {
const command =
"docker service inspect --format='{{range .Spec.TaskTemplate.ContainerSpec.Env}}{{println .}}{{end}}' dokploy-traefik";
return stdout.trim();
if (input?.serverId) {
const result = await execAsyncRemote(input.serverId, command);
return result.stdout.trim();
}
const result = await execAsync(command);
return result.stdout.trim();
}),
writeTraefikEnv: adminProcedure
.input(z.object({ env: z.string() }))
.input(z.object({ env: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input }) => {
const envs = prepareEnvironmentVariables(input.env);
await initializeTraefik({

View File

@@ -126,12 +126,25 @@ export const getContainersByAppNameMatch = async (
return [];
};
export const getContainersByAppLabel = async (appName: string) => {
export const getContainersByAppLabel = async (
appName: string,
serverId?: string,
) => {
try {
const { stdout, stderr } = await execAsync(
`docker ps --filter "label=com.docker.swarm.service.name=${appName}" --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'`,
);
let stdout = "";
let stderr = "";
const command = `docker ps --filter "label=com.docker.swarm.service.name=${appName}" --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}}'`;
console.log(command);
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;

View File

@@ -3,6 +3,7 @@ import { join } from "node:path";
import { docker } from "@/server/constants";
import { getServiceContainer } from "@/server/utils/docker/utils";
import packageInfo from "../../../package.json";
import { execAsyncRemote } from "@/server/utils/process/execAsync";
const updateIsAvailable = async () => {
try {
@@ -44,8 +45,66 @@ interface TreeDataItem {
children?: TreeDataItem[];
}
export const readDirectory = (dirPath: string): TreeDataItem[] => {
export const readDirectory = async (
dirPath: string,
serverId?: string,
): Promise<TreeDataItem[]> => {
if (serverId) {
const { stdout } = await execAsyncRemote(
serverId,
`
process_items() {
local parent_dir="$1"
local __resultvar=$2
local items_json=""
local first=true
for item in "$parent_dir"/*; do
[ -e "$item" ] || continue
process_item "$item" item_json
if [ "$first" = true ]; then
first=false
items_json="$item_json"
else
items_json="$items_json,$item_json"
fi
done
eval $__resultvar="'[$items_json]'"
}
process_item() {
local item_path="$1"
local __resultvar=$2
local item_name=$(basename "$item_path")
local escaped_name=$(echo "$item_name" | sed 's/"/\\"/g')
local escaped_path=$(echo "$item_path" | sed 's/"/\\"/g')
if [ -d "$item_path" ]; then
# Is directory
process_items "$item_path" children_json
local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"directory","children":'"$children_json"'}'
else
# Is file
local json='{"id":"'"$escaped_path"'","name":"'"$escaped_name"'","type":"file"}'
fi
eval $__resultvar="'$json'"
}
root_dir=${dirPath}
process_items "$root_dir" json_output
echo "$json_output"
`,
);
const result = JSON.parse(stdout);
return result;
}
const items = readdirSync(dirPath, { withFileTypes: true });
return items.map((item) => {
const fullPath = join(dirPath, item.name);
if (item.isDirectory()) {
@@ -61,5 +120,5 @@ export const readDirectory = (dirPath: string): TreeDataItem[] => {
name: item.name,
type: "file",
};
});
}) as unknown as Promise<TreeDataItem[]>;
};

View File

@@ -72,15 +72,24 @@ export const apiTraefikConfig = z.object({
export const apiModifyTraefikConfig = z.object({
path: z.string().min(1),
traefikConfig: z.string().min(1),
serverId: z.string().optional(),
});
export const apiReadTraefikConfig = z.object({
path: z.string().min(1),
serverId: z.string().optional(),
});
export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(),
serverId: z.string().optional(),
});
export const apiStorage = z
.object({
serverId: z.string().optional(),
})
.optional();
export const apiReadStatsLogs = z.object({
page: z
.object({

View File

@@ -149,27 +149,39 @@ export const getContainerByName = (name: string): Promise<ContainerInfo> => {
});
});
};
export const cleanUpUnusedImages = async () => {
export const cleanUpUnusedImages = async (serverId?: string) => {
try {
await execAsync("docker image prune --all --force");
if (serverId) {
await execAsyncRemote(serverId, "docker image prune --all --force");
} else {
await execAsync("docker image prune --all --force");
}
} catch (error) {
console.error(error);
throw error;
}
};
export const cleanStoppedContainers = async () => {
export const cleanStoppedContainers = async (serverId?: string) => {
try {
await execAsync("docker container prune --force");
if (serverId) {
await execAsyncRemote(serverId, "docker container prune --force");
} else {
await execAsync("docker container prune --force");
}
} catch (error) {
console.error(error);
throw error;
}
};
export const cleanUpUnusedVolumes = async () => {
export const cleanUpUnusedVolumes = async (serverId?: string) => {
try {
await execAsync("docker volume prune --all --force");
if (serverId) {
await execAsyncRemote(serverId, "docker volume prune --all --force");
} else {
await execAsync("docker volume prune --all --force");
}
} catch (error) {
console.error(error);
throw error;
@@ -193,12 +205,23 @@ export const cleanUpInactiveContainers = async () => {
}
};
export const cleanUpDockerBuilder = async () => {
await execAsync("docker builder prune --all --force");
export const cleanUpDockerBuilder = async (serverId?: string) => {
if (serverId) {
await execAsyncRemote(serverId, "docker builder prune --all --force");
} else {
await execAsync("docker builder prune --all --force");
}
};
export const cleanUpSystemPrune = async () => {
await execAsync("docker system prune --all --force --volumes");
export const cleanUpSystemPrune = async (serverId?: string) => {
if (serverId) {
await execAsyncRemote(
serverId,
"docker system prune --all --force --volumes",
);
} else {
await execAsync("docker system prune --all --force --volumes");
}
};
export const startService = async (appName: string) => {

View File

@@ -5,6 +5,7 @@ import { paths } from "@/server/constants";
import { dump, load } from "js-yaml";
import { execAsyncRemote } from "../process/execAsync";
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
import { encodeBase64 } from "../docker/utils";
export const createTraefikConfig = (appName: string) => {
const defaultPort = 3000;
@@ -146,8 +147,14 @@ export const readMonitoringConfig = () => {
return null;
};
export const readConfigInPath = (pathFile: string) => {
export const readConfigInPath = async (pathFile: string, serverId?: string) => {
const configPath = path.join(pathFile);
if (serverId) {
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
if (!stdout) return null;
return stdout;
}
if (fs.existsSync(configPath)) {
const yamlStr = fs.readFileSync(configPath, "utf8");
return yamlStr;
@@ -179,12 +186,22 @@ export const writeConfigRemote = async (
}
};
export const writeTraefikConfigInPath = (
export const writeTraefikConfigInPath = async (
pathFile: string,
traefikConfig: string,
serverId?: string,
) => {
try {
const configPath = path.join(pathFile);
if (serverId) {
const encoded = encodeBase64(traefikConfig);
await execAsyncRemote(
serverId,
`echo "${encoded}" | base64 -d > "${configPath}"`,
);
} else {
fs.writeFileSync(configPath, traefikConfig, "utf8");
}
fs.writeFileSync(configPath, traefikConfig, "utf8");
} catch (e) {
console.error("Error saving the YAML config file:", e);

View File

@@ -3,6 +3,9 @@ import { spawn } from "node-pty";
import { WebSocketServer } from "ws";
import { validateWebSocketRequest } from "../auth/auth";
import { getShell } from "./utils";
import { Client } from "ssh2";
import { readSSHKey } from "../utils/filesystem/ssh";
import { findServerById } from "../api/services/server";
export const setupDockerContainerTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -44,7 +47,123 @@ export const setupDockerContainerTerminalWebSocketServer = (
}
try {
if (serverId) {
// const server = await findServerById(serverId);
const server = await findServerById(serverId);
if (!server.sshKeyId)
throw new Error("No SSH key available for this server");
const keys = await readSSHKey(server.sshKeyId);
const conn = new Client();
let stdout = "";
let stderr = "";
// conn
// .once("ready", () => {
// console.log("Client :: ready");
// conn.exec(
// `docker run -i ${containerId} ${activeWay}`,
// (err, stream) => {
// if (err) throw err;
// stream
// .on("close", (code: number, signal: string) => {
// console.log(
// `Stream :: close :: code: ${code}, signal: ${signal}`,
// );
// conn.end();
// })
// .on("data", (data: string) => {
// stdout += data.toString();
// ws.send(data.toString());
// })
// .stderr.on("data", (data) => {
// stderr += data.toString();
// ws.send(data.toString());
// console.error("Error: ", data.toString());
// });
// // Maneja la entrada de comandos desde WebSocket
// ws.on("message", (message) => {
// try {
// let command: string | Buffer[] | Buffer | ArrayBuffer;
// if (Buffer.isBuffer(message)) {
// command = message.toString("utf8");
// } else {
// command = message;
// }
// stream.write(command.toString());
// } catch (error) {
// // @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);
// }
// });
// // Cuando se cierra la conexión WebSocket
// ws.on("close", () => {
// stream.end();
// });
// },
// );
// })p
// .connect({
// host: server.ipAddress,
// port: server.port,
// username: server.username,
// privateKey: keys.privateKey,
// timeout: 99999,
// });
// conn
// .once("ready", () => {
// console.log("Client :: ready");
// conn.shell((err, stream) => {
// if (err) throw err;
// stream
// .on("close", (code: number, signal: string) => {
// console.log(
// `Stream :: close :: code: ${code}, signal: ${signal}`,
// );
// conn.end();
// })
// .on("data", (data: string) => {
// stdout += data.toString();
// ws.send(data.toString());
// })
// .stderr.on("data", (data) => {
// stderr += data.toString();
// ws.send(data.toString());
// console.error("Error: ", data.toString());
// });
// stream.write(`docker exec -it ${containerId} ${activeWay}\n`);
// // Maneja la entrada de comandos desde WebSocket
// ws.on("message", (message) => {
// try {
// let command: string | Buffer[] | Buffer | ArrayBuffer;
// if (Buffer.isBuffer(message)) {
// command = message.toString("utf8");
// } else {
// command = message;
// }
// stream.write(command.toString());
// } catch (error) {
// // @ts-ignore
// const errorMessage = error?.message as unknown as string;
// ws.send(errorMessage);
// }
// });
// // Cuando se cierra la conexión WebSocket
// ws.on("close", () => {
// stream.end();
// });
// });
// })
// .connect({
// host: server.ipAddress,
// port: server.port,
// username: server.username,
// privateKey: keys.privateKey,
// timeout: 99999,
// });
} else {
const shell = getShell();
const ptyProcess = spawn(