Skip to content

Commit 437c1d8

Browse files
authored
feat : multi-stage build and optimization (#57)
closes #54 partially closes #59
2 parents 9b9e590 + 9b848fb commit 437c1d8

4 files changed

Lines changed: 193 additions & 42 deletions

File tree

src/backend/scripts.ts

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,79 @@
11
import { exec } from "./dependencies.ts";
2-
import dockerize from "./utils/container.ts";
2+
import dockerize, { dockerignore } from "./utils/container.ts";
33
import DfContentMap from "./types/maps_interface.ts";
44

55
const MEMORY_LIMIT = Deno.env.get("MEMORY_LIMIT");
66

7+
function shellEscape(input: string, label = "input"): string {
8+
if (!input) return "";
9+
if (input.startsWith("-")) {
10+
throw new Error(`[scripts] Invalid ${label}: cannot start with a hyphen`);
11+
}
12+
const safeCharPattern = /^[a-zA-Z0-9.\-_:/~?=#]+$/;
13+
14+
if (!safeCharPattern.test(input)) {
15+
throw new Error(`[scripts] Invalid characters in ${label}: ${input}`);
16+
}
17+
return input;
18+
}
19+
20+
async function safeExec(command: string): Promise<void> {
21+
try {
22+
await exec(command);
23+
} catch (error) {
24+
console.error(`[scripts] exec failed: ${command}`);
25+
console.error(error);
26+
throw error;
27+
}
28+
}
29+
730
async function addScript(
831
document: DfContentMap,
932
env_content: string,
1033
static_content: string,
11-
dockerfile_present:string,
34+
dockerfile_present: string,
1235
stack: string,
1336
port: string,
1437
build_cmds: string,
1538
) {
39+
const subdomain = shellEscape(document.subdomain, "subdomain");
40+
const resource = shellEscape(document.resource, "resource");
41+
const safePort = shellEscape(port, "port");
42+
const memLimit = shellEscape(MEMORY_LIMIT || "512m", "MEMORY_LIMIT");
43+
1644
if (document.resource_type === "URL") {
17-
await exec(
18-
`bash -c "echo 'bash ../../src/backend/shell_scripts/automate.sh -u ${document.resource} ${document.subdomain}' > /hostpipe/pipe"`,
45+
await safeExec(
46+
`bash -c "echo 'bash ../../src/backend/shell_scripts/automate.sh -u ${resource} ${subdomain}' > /hostpipe/pipe"`,
1947
);
2048
} else if (document.resource_type === "PORT") {
21-
await exec(
22-
`bash -c "echo 'bash ../../src/backend/shell_scripts/automate.sh -p ${document.resource} ${document.subdomain}' > /hostpipe/pipe"`,
49+
await safeExec(
50+
`bash -c "echo 'bash ../../src/backend/shell_scripts/automate.sh -p ${resource} ${subdomain}' > /hostpipe/pipe"`,
2351
);
2452
} else if (document.resource_type === "GITHUB" && static_content == "Yes") {
25-
Deno.writeTextFile(`/hostpipe/.env`, env_content);
26-
await exec(
27-
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -s ${document.subdomain} ${document.resource} 80 ${MEMORY_LIMIT}' > /hostpipe/pipe"`,
53+
await Deno.writeTextFile(`/hostpipe/.env`, env_content);
54+
await safeExec(
55+
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -s ${subdomain} ${resource} 80 ${memLimit}' > /hostpipe/pipe"`,
2856
);
2957
} else if (document.resource_type === "GITHUB" && static_content == "No") {
30-
if(dockerfile_present === 'No'){
31-
const dockerfile = dockerize(stack, port, build_cmds);
32-
Deno.writeTextFile(`/hostpipe/Dockerfile`, dockerfile);
33-
Deno.writeTextFile(`/hostpipe/.env`, env_content);
34-
await exec(
35-
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -g ${document.subdomain} ${document.resource} ${port} ${MEMORY_LIMIT}' > /hostpipe/pipe"`,
36-
);
37-
}else if(dockerfile_present === 'Yes'){
38-
39-
await exec(
40-
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -d ${document.subdomain} ${document.resource} ${port} ${MEMORY_LIMIT}' > /hostpipe/pipe"`,
58+
if (dockerfile_present === 'No') {
59+
await Deno.writeTextFile(`/hostpipe/Dockerfile`, dockerize(stack, safePort, build_cmds));
60+
await Deno.writeTextFile(`/hostpipe/.dockerignore`, dockerignore(stack));
61+
await Deno.writeTextFile(`/hostpipe/.env`, env_content);
62+
await safeExec(
63+
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -g ${subdomain} ${resource} ${safePort} ${memLimit}' > /hostpipe/pipe"`,
64+
);
65+
} else if (dockerfile_present === 'Yes') {
66+
await safeExec(
67+
`bash -c "echo 'bash ../../src/backend/shell_scripts/container.sh -d ${subdomain} ${resource} ${safePort} ${memLimit}' > /hostpipe/pipe"`,
4168
);
4269
}
43-
4470
}
4571
}
4672

4773
async function deleteScript(document: DfContentMap) {
48-
await exec(
49-
`bash -c "echo 'bash ../../src/backend/shell_scripts/delete.sh ${document.subdomain}' > /hostpipe/pipe"`,
74+
const subdomain = shellEscape(document.subdomain, "subdomain");
75+
await safeExec(
76+
`bash -c "echo 'bash ../../src/backend/shell_scripts/delete.sh ${subdomain}' > /hostpipe/pipe"`,
5077
);
5178
}
5279

src/backend/shell_scripts/container.sh

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ cd $name
2323

2424
if [ $flag = "-g" ]; then
2525
sudo cp ../Dockerfile ./
26+
sudo cp ../.dockerignore ./ 2>/dev/null || true
2627
elif [ $flag = "-s" ]; then
2728
sudo echo "
2829
FROM nginx:alpine
@@ -31,18 +32,18 @@ elif [ $flag = "-s" ]; then
3132
fi
3233

3334
sudo docker build -t $name .
34-
sudo docker run --memory=$max_mem --name=$name -d -p ${available_ports[$AVAILABLE]}:$exp_port $2
35+
sudo docker run --memory=$max_mem --name=$name -d -p ${available_ports[$AVAILABLE]}:$exp_port $name
3536
cd ..
3637
sudo rm -rf $name
3738
sudo rm Dockerfile
3839
sudo rm .env
39-
sudo touch /etc/nginx/sites-available/$2.conf
40-
sudo chmod 666 /etc/nginx/sites-available/$2.conf
41-
sudo echo "# Virtual Host configuration for $2
40+
sudo touch /etc/nginx/sites-available/$name.conf
41+
sudo chmod 666 /etc/nginx/sites-available/$name.conf
42+
sudo echo "# Virtual Host configuration for $name
4243
server {
4344
listen 80;
4445
listen [::]:80;
45-
server_name $2;
46+
server_name $name;
4647
location / {
4748
proxy_pass http://localhost:${available_ports[$AVAILABLE]};
4849
proxy_http_version 1.1;
@@ -53,6 +54,6 @@ sudo echo "# Virtual Host configuration for $2
5354
}
5455
charset utf-8;
5556
client_max_body_size 20M;
56-
}" > /etc/nginx/sites-available/$2.conf
57-
sudo ln -s /etc/nginx/sites-available/$2.conf /etc/nginx/sites-enabled/$2.conf
57+
}" > /etc/nginx/sites-available/$name.conf
58+
sudo ln -s /etc/nginx/sites-available/$name.conf /etc/nginx/sites-enabled/$name.conf
5859
sudo systemctl reload nginx

src/backend/utils/container.ts

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,142 @@ export default function dockerize(
55
) {
66
let dockerfile = "";
77
build_cmds = build_cmds.replace(/\r?\n$/, '');
8-
const run_cmd = build_cmds.split("\n");
9-
const execute_cmd = "CMD " + JSON.stringify(run_cmd.pop()?.split(" "));
10-
const build_cmds_mapped = run_cmd.map((elem) => {
11-
return "RUN " + elem;
12-
}).join("\n");
8+
const run_cmd = build_cmds.split("\n").map(c => c.trim()).filter(Boolean);
9+
const last_cmd = run_cmd.pop();
10+
if (!last_cmd) {
11+
throw new Error("build_cmds must contain at least one valid execution command");
12+
}
13+
let execute_cmd = "CMD " + JSON.stringify(last_cmd.split(" "));
14+
let build_steps = run_cmd.filter(Boolean).map((cmd) => `RUN ${cmd}`);
1315
if (stack == "Python") {
14-
dockerfile =
15-
"FROM python:latest \nWORKDIR /app \nCOPY requirements.txt . \nRUN pip install --no-cache-dir -r requirements.txt \nCOPY . ." +
16-
build_cmds_mapped + `\nEXPOSE ${port}\n` + execute_cmd;
16+
dockerfile = [
17+
"FROM python:3.12-slim AS builder",
18+
"WORKDIR /app",
19+
"RUN python -m venv /opt/venv",
20+
"ENV PATH=\"/opt/venv/bin:$PATH\"",
21+
"COPY . .",
22+
"RUN if [ -f requirements.txt ]; then pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt; fi",
23+
...build_steps,
24+
"",
25+
"FROM python:3.12-slim",
26+
"RUN groupadd -r appuser && useradd -r -g appuser appuser",
27+
"WORKDIR /app",
28+
"COPY --from=builder /opt/venv /opt/venv",
29+
"COPY --from=builder /app /app",
30+
"ENV PATH=\"/opt/venv/bin:$PATH\"",
31+
"USER appuser",
32+
`EXPOSE ${port}`,
33+
execute_cmd,
34+
].join("\n");
1735
} else if (stack == "NodeJS") {
18-
dockerfile =
19-
"FROM node:latest \nWORKDIR /app \nCOPY ./package*.json . \nRUN npm install \nCOPY . ." +
20-
build_cmds_mapped + `\nEXPOSE ${port} \n` + execute_cmd;
36+
dockerfile = [
37+
"FROM node:22-alpine AS builder",
38+
"WORKDIR /app",
39+
"COPY . .",
40+
"RUN if [ -f package.json ]; then npm install && npm cache clean --force; fi",
41+
...build_steps,
42+
"RUN rm -rf node_modules",
43+
"",
44+
"FROM node:22-alpine",
45+
"WORKDIR /app",
46+
"ENV NODE_ENV=production",
47+
"COPY --from=builder /app ./",
48+
"RUN if [ -f package.json ]; then npm install --omit=dev && npm cache clean --force; fi",
49+
"USER node",
50+
`EXPOSE ${port}`,
51+
execute_cmd,
52+
].join("\n");
53+
} else if (stack === "Go") {
54+
let goBuildOverride: string[] = [];
55+
if (last_cmd.startsWith("go run")) {
56+
const target = last_cmd.replace("go run ", "");
57+
goBuildOverride = [`RUN go build -o app_binary ${target}`];
58+
execute_cmd = 'CMD ["./app_binary"]';
59+
}
60+
dockerfile = [
61+
"FROM golang:1.22-alpine AS builder",
62+
"WORKDIR /app",
63+
"COPY . .",
64+
"RUN if [ -f go.mod ]; then go mod download; fi",
65+
...build_steps,
66+
...goBuildOverride,
67+
"RUN find . -type f ! -executable -delete && rm -rf vendor/ .git/ *.go go.*",
68+
"",
69+
"FROM alpine:3.19",
70+
"RUN addgroup -S appgroup && adduser -S appuser -G appgroup",
71+
"RUN apk add --no-cache ca-certificates",
72+
"WORKDIR /app",
73+
"COPY --from=builder /app /app",
74+
"USER appuser",
75+
`EXPOSE ${port}`,
76+
execute_cmd,
77+
].join("\n");
78+
} else if (stack === "Rust") {
79+
let rustBuildOverride: string[] = [];
80+
let processed_build_steps = build_steps;
81+
82+
if (last_cmd.startsWith("cargo run")) {
83+
processed_build_steps = build_steps.filter(step => !step.includes("cargo build"));
84+
rustBuildOverride = [`RUN cargo build --release && find target/release -maxdepth 1 -type f -executable -exec mv {} ./app_binary \\;`];
85+
execute_cmd = 'CMD ["./app_binary"]';
86+
} else if (last_cmd.startsWith("rustc ")) {
87+
const target = last_cmd.replace("rustc ", "");
88+
processed_build_steps = build_steps.filter(step => !step.includes("rustc "));
89+
rustBuildOverride = [`RUN rustc ${target} -o app_binary`];
90+
execute_cmd = 'CMD ["./app_binary"]';
91+
}
92+
93+
dockerfile = [
94+
"FROM rust:alpine AS builder",
95+
"RUN apk add --no-cache musl-dev",
96+
"WORKDIR /app",
97+
"COPY . .",
98+
...processed_build_steps,
99+
...rustBuildOverride,
100+
"# Generator limitation: Since we don't know the exact binary name, we delete all source files and keep only the compiled executables",
101+
"RUN find . -type f ! -executable -delete && rm -rf src/ .git/ Cargo.* vendor/ target/",
102+
"",
103+
"FROM alpine:3.19",
104+
"RUN addgroup -S appgroup && adduser -S appuser -G appgroup",
105+
"RUN apk add --no-cache ca-certificates libgcc",
106+
"WORKDIR /app",
107+
"COPY --from=builder /app /app",
108+
"USER appuser",
109+
`EXPOSE ${port}`,
110+
execute_cmd,
111+
].join("\n");
112+
} else if (stack === "React") {
113+
dockerfile = [
114+
"FROM node:22-alpine AS builder",
115+
"WORKDIR /app",
116+
"COPY . .",
117+
"RUN if [ -f package.json ]; then npm install && npm cache clean --force; fi",
118+
...run_cmd.map((cmd) => `RUN ${cmd}`),
119+
`RUN ${last_cmd}`,
120+
"RUN if [ -d \"build\" ]; then mv build /app_output; elif [ -d \"dist\" ]; then mv dist /app_output; else echo \"No build or dist folder found!\" && exit 1; fi",
121+
"",
122+
"FROM nginx:alpine",
123+
"COPY --from=builder /app_output /usr/share/nginx/html",
124+
`RUN printf "server {\\n listen ${port};\\n location / {\\n root /usr/share/nginx/html;\\n index index.html index.htm;\\n try_files \\$uri \\$uri/ /index.html;\\n }\\n}" > /etc/nginx/conf.d/default.conf`,
125+
`EXPOSE ${port}`,
126+
].join("\n");
21127
}
22128
return dockerfile.toString();
23129
}
130+
131+
export function dockerignore(stack: string): string {
132+
const common = [
133+
".git", ".gitignore", ".dockerignore",
134+
"*.md", ".DS_Store",
135+
];
136+
137+
const stackRules: Record<string, string[]> = {
138+
Python: ["__pycache__/", "*.pyc", "*.pyo", ".venv/", "dist/", "*.egg-info/"],
139+
NodeJS: ["node_modules/", "dist/", ".npm/", "*.log", "coverage/"],
140+
Go: ["bin/", "obj/", "*.exe", "*.dll", "*.so", "*.dylib"],
141+
Rust: ["target/", "**/*.rs.bk"],
142+
React: ["node_modules/", "build/", "dist/", ".npm/", "*.log", "coverage/"],
143+
};
144+
145+
return [...common, ...(stackRules[stack] ?? [])].join("\n") + "\n";
146+
}

src/frontend/src/components/modal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export default {
6565
stack: '',
6666
build_cmds: '',
6767
resourceTypes: ['URL', 'PORT', 'GITHUB'],
68-
stacks: ['Python', 'NodeJS']
68+
stacks: ['Python', 'NodeJS', 'Go', 'Rust', 'React']
6969
};
7070
},
7171
methods: {

0 commit comments

Comments
 (0)