@better-openclaw/core 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +8 -8
- package/.github/workflows/publish-core.yml +4 -4
- package/SECURITY.md +62 -0
- package/dist/deployers/coolify.cjs +61 -50
- package/dist/deployers/coolify.cjs.map +1 -1
- package/dist/deployers/coolify.d.cts +4 -11
- package/dist/deployers/coolify.d.cts.map +1 -1
- package/dist/deployers/coolify.d.mts +4 -11
- package/dist/deployers/coolify.d.mts.map +1 -1
- package/dist/deployers/coolify.mjs +62 -50
- package/dist/deployers/coolify.mjs.map +1 -1
- package/dist/deployers/dokploy.cjs +106 -29
- package/dist/deployers/dokploy.cjs.map +1 -1
- package/dist/deployers/dokploy.d.cts +2 -1
- package/dist/deployers/dokploy.d.cts.map +1 -1
- package/dist/deployers/dokploy.d.mts +2 -1
- package/dist/deployers/dokploy.d.mts.map +1 -1
- package/dist/deployers/dokploy.mjs +107 -29
- package/dist/deployers/dokploy.mjs.map +1 -1
- package/dist/deployers/index.cjs.map +1 -1
- package/dist/deployers/index.d.cts +2 -2
- package/dist/deployers/index.d.cts.map +1 -1
- package/dist/deployers/index.d.mts +2 -2
- package/dist/deployers/index.d.mts.map +1 -1
- package/dist/deployers/index.mjs.map +1 -1
- package/dist/deployers/strip-host-ports.cjs +138 -0
- package/dist/deployers/strip-host-ports.cjs.map +1 -0
- package/dist/deployers/strip-host-ports.d.cts +62 -0
- package/dist/deployers/strip-host-ports.d.cts.map +1 -0
- package/dist/deployers/strip-host-ports.d.mts +62 -0
- package/dist/deployers/strip-host-ports.d.mts.map +1 -0
- package/dist/deployers/strip-host-ports.mjs +133 -0
- package/dist/deployers/strip-host-ports.mjs.map +1 -0
- package/dist/deployers/strip-host-ports.test.cjs +89 -0
- package/dist/deployers/strip-host-ports.test.cjs.map +1 -0
- package/dist/deployers/strip-host-ports.test.d.cts +1 -0
- package/dist/deployers/strip-host-ports.test.d.mts +1 -0
- package/dist/deployers/strip-host-ports.test.mjs +90 -0
- package/dist/deployers/strip-host-ports.test.mjs.map +1 -0
- package/dist/deployers/types.d.cts +173 -2
- package/dist/deployers/types.d.cts.map +1 -1
- package/dist/deployers/types.d.mts +173 -2
- package/dist/deployers/types.d.mts.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +2 -2
- package/dist/services/definitions/usesend.cjs +4 -4
- package/dist/services/definitions/usesend.cjs.map +1 -1
- package/dist/services/definitions/usesend.mjs +4 -4
- package/dist/services/definitions/usesend.mjs.map +1 -1
- package/package.json +4 -4
- package/src/__snapshots__/composer.snapshot.test.ts.snap +248 -38
- package/src/deployers/coolify.ts +198 -103
- package/src/deployers/dokploy.ts +209 -55
- package/src/deployers/index.ts +1 -0
- package/src/deployers/strip-host-ports.test.ts +100 -0
- package/src/deployers/strip-host-ports.ts +187 -0
- package/src/deployers/types.ts +185 -1
- package/src/index.ts +19 -4
- package/src/services/definitions/usesend.ts +4 -4
- package/tsconfig.tsbuildinfo +1 -0
package/src/deployers/dokploy.ts
CHANGED
|
@@ -6,21 +6,37 @@
|
|
|
6
6
|
* Endpoints use dot-notation (e.g. /api/project.create, /api/compose.deploy)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import { sanitizeComposeForPaas } from "./strip-host-ports.js";
|
|
10
|
+
import type {
|
|
11
|
+
DeployInput,
|
|
12
|
+
DeployResult,
|
|
13
|
+
DeployStep,
|
|
14
|
+
DeployTarget,
|
|
15
|
+
DokployEnvironment,
|
|
16
|
+
PaasDeployer,
|
|
17
|
+
PaasServer,
|
|
18
|
+
} from "./types.js";
|
|
10
19
|
|
|
11
|
-
/** Shape returned by Dokploy's project endpoints. */
|
|
12
20
|
interface DokployProject {
|
|
13
21
|
projectId: string;
|
|
14
22
|
name: string;
|
|
15
23
|
description: string;
|
|
16
|
-
environments?:
|
|
24
|
+
environments?: DokployEnvironment[];
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
/** Shape returned by Dokploy's compose endpoints. */
|
|
20
27
|
interface DokployCompose {
|
|
21
28
|
composeId: string;
|
|
22
29
|
name: string;
|
|
23
30
|
status?: string;
|
|
31
|
+
compose?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ProjectCreateResult {
|
|
35
|
+
project: DokployProject;
|
|
36
|
+
projectId: string;
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
environments?: { environmentId: string; name: string }[];
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
/** Build a full Dokploy API URL from a dot-notation endpoint (e.g. "project.create"). */
|
|
@@ -28,7 +44,6 @@ function apiUrl(target: DeployTarget, endpoint: string): string {
|
|
|
28
44
|
const base = target.instanceUrl.replace(/\/+$/, "");
|
|
29
45
|
return `${base}/api/${endpoint}`;
|
|
30
46
|
}
|
|
31
|
-
|
|
32
47
|
/**
|
|
33
48
|
* Typed fetch wrapper for the Dokploy API.
|
|
34
49
|
* Handles JSON serialisation, x-api-key auth, and error extraction.
|
|
@@ -50,20 +65,35 @@ async function dokployFetch<T>(
|
|
|
50
65
|
if (!res.ok) {
|
|
51
66
|
const text = await res.text().catch(() => "");
|
|
52
67
|
let detail = text;
|
|
68
|
+
|
|
53
69
|
try {
|
|
54
70
|
const json = JSON.parse(text);
|
|
55
71
|
detail = json.message || json.error || text;
|
|
56
|
-
} catch {
|
|
57
|
-
|
|
58
|
-
}
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
59
74
|
throw new Error(`Dokploy API ${res.status}: ${detail}`);
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
const text = await res.text();
|
|
63
78
|
if (!text) return undefined as T;
|
|
79
|
+
|
|
64
80
|
return JSON.parse(text) as T;
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Simple hash for compose diff detection
|
|
85
|
+
*/
|
|
86
|
+
function hashString(str: string) {
|
|
87
|
+
let hash = 0;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < str.length; i++) {
|
|
90
|
+
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
91
|
+
hash |= 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return hash;
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
/**
|
|
68
98
|
* Deploys Docker Compose stacks to a Dokploy instance.
|
|
69
99
|
*
|
|
@@ -73,6 +103,7 @@ async function dokployFetch<T>(
|
|
|
73
103
|
* 3. Push .env variables to the compose stack
|
|
74
104
|
* 4. Trigger the deployment
|
|
75
105
|
*/
|
|
106
|
+
|
|
76
107
|
export class DokployDeployer implements PaasDeployer {
|
|
77
108
|
readonly name = "Dokploy";
|
|
78
109
|
readonly id = "dokploy";
|
|
@@ -80,94 +111,217 @@ export class DokployDeployer implements PaasDeployer {
|
|
|
80
111
|
async testConnection(target: DeployTarget): Promise<{ ok: boolean; error?: string }> {
|
|
81
112
|
try {
|
|
82
113
|
await dokployFetch<DokployProject[]>(target, "project.all");
|
|
114
|
+
|
|
83
115
|
return { ok: true };
|
|
84
116
|
} catch (err) {
|
|
85
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: err instanceof Error ? err.message : String(err),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async listServers(target: DeployTarget): Promise<PaasServer[]> {
|
|
125
|
+
try {
|
|
126
|
+
const servers = await dokployFetch<{ serverId: string; name: string; ipAddress: string }[]>(
|
|
127
|
+
target,
|
|
128
|
+
"server.all",
|
|
129
|
+
);
|
|
130
|
+
return servers.map((s) => ({
|
|
131
|
+
id: s.serverId,
|
|
132
|
+
name: s.name,
|
|
133
|
+
ip: s.ipAddress,
|
|
134
|
+
}));
|
|
135
|
+
} catch {
|
|
136
|
+
// Return empty list if server API is not available
|
|
137
|
+
return [];
|
|
86
138
|
}
|
|
87
139
|
}
|
|
88
140
|
|
|
89
141
|
async deploy(input: DeployInput): Promise<DeployResult> {
|
|
90
|
-
const step1: DeployStep = {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
142
|
+
const step1: DeployStep = {
|
|
143
|
+
step: "Find or create project",
|
|
144
|
+
status: "pending",
|
|
145
|
+
};
|
|
146
|
+
const step2: DeployStep = {
|
|
147
|
+
step: "Find default environment",
|
|
148
|
+
status: "pending",
|
|
149
|
+
};
|
|
150
|
+
const step3: DeployStep = {
|
|
151
|
+
step: "Find or create compose stack",
|
|
152
|
+
status: "pending",
|
|
153
|
+
};
|
|
154
|
+
const step4: DeployStep = {
|
|
155
|
+
step: "Update stack configuration",
|
|
156
|
+
status: "pending",
|
|
157
|
+
};
|
|
158
|
+
const step5: DeployStep = { step: "Deploy stack", status: "pending" };
|
|
159
|
+
const steps: DeployStep[] = [step1, step2, step3, step4, step5];
|
|
95
160
|
|
|
96
161
|
const result: DeployResult = { success: false, steps };
|
|
97
162
|
|
|
163
|
+
// Strip host port bindings — Dokploy routes via Traefik,
|
|
164
|
+
// so host ports are unnecessary and cause "port already allocated" errors.
|
|
165
|
+
const composeYaml = sanitizeComposeForPaas(input.composeYaml);
|
|
166
|
+
|
|
98
167
|
try {
|
|
99
|
-
|
|
168
|
+
/**
|
|
169
|
+
* STEP 1
|
|
170
|
+
* Find or create project
|
|
171
|
+
*/
|
|
172
|
+
|
|
100
173
|
step1.status = "running";
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
174
|
+
|
|
175
|
+
const projects = await dokployFetch<DokployProject[]>(input.target, "project.all");
|
|
176
|
+
|
|
177
|
+
let project = projects.find((p) => p.name === input.projectName);
|
|
178
|
+
|
|
179
|
+
if (!project) {
|
|
180
|
+
const created = await dokployFetch<ProjectCreateResult>(input.target, "project.create", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: {
|
|
183
|
+
name: input.projectName,
|
|
184
|
+
description: input.description ?? `OpenClaw stack: ${input.projectName}`,
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
project = created.project;
|
|
189
|
+
}
|
|
190
|
+
|
|
108
191
|
result.projectId = project.projectId;
|
|
192
|
+
|
|
109
193
|
step1.status = "done";
|
|
110
194
|
step1.detail = `Project ID: ${project.projectId}`;
|
|
111
195
|
|
|
112
|
-
|
|
196
|
+
/**
|
|
197
|
+
* STEP 2
|
|
198
|
+
* Find default environment
|
|
199
|
+
*/
|
|
200
|
+
|
|
201
|
+
step2.status = "running";
|
|
202
|
+
|
|
113
203
|
const projectDetail = await dokployFetch<DokployProject>(
|
|
114
204
|
input.target,
|
|
115
205
|
`project.one?projectId=${project.projectId}`,
|
|
116
206
|
);
|
|
117
|
-
const envId = projectDetail.environments?.[0]?.environmentId;
|
|
118
|
-
if (!envId) {
|
|
119
|
-
throw new Error("No default environment found in project");
|
|
120
|
-
}
|
|
121
207
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
body: {
|
|
127
|
-
name: input.projectName,
|
|
128
|
-
environmentId: envId,
|
|
129
|
-
composeFile: input.composeYaml,
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
result.composeId = compose.composeId;
|
|
208
|
+
const env = projectDetail.environments?.find((e) => e.isDefault);
|
|
209
|
+
|
|
210
|
+
if (!env) throw new Error("No default environment");
|
|
211
|
+
|
|
133
212
|
step2.status = "done";
|
|
134
|
-
step2.detail =
|
|
213
|
+
step2.detail = env.environmentId;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* STEP 3
|
|
217
|
+
* Find or create compose stack
|
|
218
|
+
*/
|
|
135
219
|
|
|
136
|
-
// Step 3: Set environment variables
|
|
137
220
|
step3.status = "running";
|
|
138
|
-
|
|
221
|
+
|
|
222
|
+
let stack: DokployCompose | null = null;
|
|
223
|
+
|
|
224
|
+
stack = await dokployFetch<DokployCompose>(input.target, "compose.create", {
|
|
139
225
|
method: "POST",
|
|
140
226
|
body: {
|
|
141
|
-
|
|
142
|
-
|
|
227
|
+
name: input.projectName,
|
|
228
|
+
description: input.description ?? `Stack ${input.projectName}`,
|
|
229
|
+
environmentId: env.environmentId,
|
|
230
|
+
composeType: "docker-compose",
|
|
231
|
+
composeFile: composeYaml,
|
|
232
|
+
...(input.serverId ? { serverId: input.serverId } : {}),
|
|
143
233
|
},
|
|
144
234
|
});
|
|
235
|
+
|
|
236
|
+
// Dokploy's compose.create schema does NOT accept sourceType;
|
|
237
|
+
// it defaults to "github". We must update it to "raw" so the
|
|
238
|
+
// deploy step writes the compose file from the stored YAML
|
|
239
|
+
// instead of attempting to clone from a Git provider.
|
|
240
|
+
if (stack?.composeId) {
|
|
241
|
+
await dokployFetch(input.target, "compose.update", {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: {
|
|
244
|
+
composeId: stack.composeId,
|
|
245
|
+
sourceType: "raw",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
result.composeId = stack?.composeId;
|
|
145
251
|
step3.status = "done";
|
|
252
|
+
step3.detail = stack?.composeId;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* STEP 4
|
|
256
|
+
* Update stack if compose changed
|
|
257
|
+
*/
|
|
146
258
|
|
|
147
|
-
// Step 4: Trigger deployment
|
|
148
259
|
step4.status = "running";
|
|
260
|
+
|
|
261
|
+
const existingStack = await dokployFetch<DokployCompose>(
|
|
262
|
+
input.target,
|
|
263
|
+
`compose.one?composeId=${stack?.composeId}`,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const newHash = hashString(composeYaml);
|
|
267
|
+
const oldHash = hashString(existingStack.compose ?? "");
|
|
268
|
+
|
|
269
|
+
if (newHash !== oldHash) {
|
|
270
|
+
await dokployFetch(input.target, "compose.update", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
body: {
|
|
273
|
+
composeId: stack?.composeId,
|
|
274
|
+
composeFile: composeYaml,
|
|
275
|
+
env: input.envContent ?? "",
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
step4.detail = "Stack updated";
|
|
280
|
+
} else {
|
|
281
|
+
step4.detail = "No compose changes";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
step4.status = "done";
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* STEP 5
|
|
288
|
+
* Deploy
|
|
289
|
+
*/
|
|
290
|
+
|
|
291
|
+
step5.status = "running";
|
|
292
|
+
|
|
149
293
|
await dokployFetch(input.target, "compose.deploy", {
|
|
150
294
|
method: "POST",
|
|
151
295
|
body: {
|
|
152
|
-
composeId:
|
|
153
|
-
|
|
154
|
-
|
|
296
|
+
composeId: stack?.composeId,
|
|
297
|
+
|
|
298
|
+
title: `Deploy ${input.projectName}`,
|
|
299
|
+
|
|
300
|
+
description: "CI deployment",
|
|
155
301
|
},
|
|
156
302
|
});
|
|
157
|
-
|
|
303
|
+
|
|
304
|
+
step5.status = "done";
|
|
158
305
|
|
|
159
306
|
result.success = true;
|
|
307
|
+
|
|
160
308
|
const base = input.target.instanceUrl.replace(/\/+$/, "");
|
|
161
|
-
|
|
309
|
+
|
|
310
|
+
result.dashboardUrl = `${base}/dashboard/project/${project.projectId}/environment/${env.environmentId}/services/compose/${stack?.composeId}?tab=deployments`;
|
|
311
|
+
|
|
312
|
+
return result;
|
|
162
313
|
} catch (err) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
314
|
+
const running = steps.find((s) => s.status === "running");
|
|
315
|
+
|
|
316
|
+
if (running) {
|
|
317
|
+
running.status = "error";
|
|
318
|
+
|
|
319
|
+
running.detail = err instanceof Error ? err.message : String(err);
|
|
167
320
|
}
|
|
321
|
+
|
|
168
322
|
result.error = err instanceof Error ? err.message : String(err);
|
|
169
|
-
}
|
|
170
323
|
|
|
171
|
-
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
172
326
|
}
|
|
173
327
|
}
|
package/src/deployers/index.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stripHostPorts } from "./strip-host-ports.js";
|
|
3
|
+
|
|
4
|
+
describe("stripHostPorts", () => {
|
|
5
|
+
it("strips host port from short syntax (host:container)", () => {
|
|
6
|
+
const yaml = `
|
|
7
|
+
services:
|
|
8
|
+
web:
|
|
9
|
+
image: nginx
|
|
10
|
+
ports:
|
|
11
|
+
- "8080:80"
|
|
12
|
+
- "443:443"
|
|
13
|
+
`;
|
|
14
|
+
const result = stripHostPorts(yaml);
|
|
15
|
+
expect(result).toContain('"80"');
|
|
16
|
+
expect(result).toContain('"443"');
|
|
17
|
+
expect(result).not.toContain("8080");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("strips host IP and port from extended short syntax", () => {
|
|
21
|
+
const yaml = `
|
|
22
|
+
services:
|
|
23
|
+
web:
|
|
24
|
+
image: nginx
|
|
25
|
+
ports:
|
|
26
|
+
- "0.0.0.0:8080:80"
|
|
27
|
+
`;
|
|
28
|
+
const result = stripHostPorts(yaml);
|
|
29
|
+
expect(result).toContain('"80"');
|
|
30
|
+
expect(result).not.toContain("8080");
|
|
31
|
+
expect(result).not.toContain("0.0.0.0");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("preserves protocol suffix", () => {
|
|
35
|
+
const yaml = `
|
|
36
|
+
services:
|
|
37
|
+
web:
|
|
38
|
+
image: nginx
|
|
39
|
+
ports:
|
|
40
|
+
- "8080:80/tcp"
|
|
41
|
+
- "5353:53/udp"
|
|
42
|
+
`;
|
|
43
|
+
const result = stripHostPorts(yaml);
|
|
44
|
+
expect(result).toContain("80/tcp");
|
|
45
|
+
expect(result).toContain("53/udp");
|
|
46
|
+
expect(result).not.toContain("8080");
|
|
47
|
+
expect(result).not.toContain("5353");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps container-only ports unchanged", () => {
|
|
51
|
+
const yaml = `
|
|
52
|
+
services:
|
|
53
|
+
web:
|
|
54
|
+
image: nginx
|
|
55
|
+
ports:
|
|
56
|
+
- "80"
|
|
57
|
+
`;
|
|
58
|
+
const result = stripHostPorts(yaml);
|
|
59
|
+
expect(result).toContain('"80"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles multiple services", () => {
|
|
63
|
+
const yaml = `
|
|
64
|
+
services:
|
|
65
|
+
web:
|
|
66
|
+
image: nginx
|
|
67
|
+
ports:
|
|
68
|
+
- "8080:80"
|
|
69
|
+
redis:
|
|
70
|
+
image: redis
|
|
71
|
+
ports:
|
|
72
|
+
- "6379:6379"
|
|
73
|
+
searxng:
|
|
74
|
+
image: searxng/searxng
|
|
75
|
+
ports:
|
|
76
|
+
- "8888:8080"
|
|
77
|
+
`;
|
|
78
|
+
const result = stripHostPorts(yaml);
|
|
79
|
+
expect(result).toContain('"80"');
|
|
80
|
+
expect(result).toContain('"6379"');
|
|
81
|
+
expect(result).toContain('"8080"');
|
|
82
|
+
expect(result).not.toContain("8888");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns original YAML if no services section", () => {
|
|
86
|
+
const yaml = `version: "3"`;
|
|
87
|
+
const result = stripHostPorts(yaml);
|
|
88
|
+
expect(result).toBe(yaml);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("handles services with no ports", () => {
|
|
92
|
+
const yaml = `
|
|
93
|
+
services:
|
|
94
|
+
web:
|
|
95
|
+
image: nginx
|
|
96
|
+
`;
|
|
97
|
+
const result = stripHostPorts(yaml);
|
|
98
|
+
expect(result).toContain("nginx");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips host port bindings from a Docker Compose YAML string.
|
|
3
|
+
*
|
|
4
|
+
* When deploying to a PaaS like Dokploy or Coolify, services don't need
|
|
5
|
+
* host port mappings because routing is handled by the platform's built-in
|
|
6
|
+
* reverse proxy (Traefik). Binding to host ports causes "port already
|
|
7
|
+
* allocated" errors when ports are in use by other services on the server.
|
|
8
|
+
*
|
|
9
|
+
* Transforms port mappings:
|
|
10
|
+
* "8080:8080" → "8080" (container port only)
|
|
11
|
+
* "0.0.0.0:8080:80" → "80" (container port only)
|
|
12
|
+
* "8080:80/tcp" → "80/tcp" (preserves protocol)
|
|
13
|
+
* { published: 8080, target: 80 } → { target: 80 }
|
|
14
|
+
*
|
|
15
|
+
* The `expose` field is left untouched since it only defines internal ports.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parse, stringify } from "yaml";
|
|
19
|
+
|
|
20
|
+
interface ComposeService {
|
|
21
|
+
ports?: (string | PortObject)[];
|
|
22
|
+
volumes?: (string | Record<string, unknown>)[];
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PortObject {
|
|
27
|
+
target: number;
|
|
28
|
+
published?: number | string;
|
|
29
|
+
host_ip?: string;
|
|
30
|
+
protocol?: string;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ComposeFile {
|
|
35
|
+
services?: Record<string, ComposeService>;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strips host port bindings from a Docker Compose YAML string,
|
|
41
|
+
* keeping only the container (target) ports.
|
|
42
|
+
*
|
|
43
|
+
* Returns the modified YAML string.
|
|
44
|
+
*/
|
|
45
|
+
export function stripHostPorts(composeYaml: string): string {
|
|
46
|
+
const doc = parse(composeYaml) as ComposeFile;
|
|
47
|
+
|
|
48
|
+
if (!doc?.services) return composeYaml;
|
|
49
|
+
|
|
50
|
+
for (const [, service] of Object.entries(doc.services)) {
|
|
51
|
+
if (!service.ports || !Array.isArray(service.ports)) continue;
|
|
52
|
+
|
|
53
|
+
service.ports = service.ports.map((port) => {
|
|
54
|
+
if (typeof port === "string") {
|
|
55
|
+
return stripStringPort(port);
|
|
56
|
+
}
|
|
57
|
+
if (typeof port === "object" && port !== null) {
|
|
58
|
+
return stripObjectPort(port);
|
|
59
|
+
}
|
|
60
|
+
return port;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return stringify(doc, { lineWidth: 200 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strips host portion from a string port mapping.
|
|
69
|
+
*
|
|
70
|
+
* "8080:80" → "80"
|
|
71
|
+
* "0.0.0.0:8080:80" → "80"
|
|
72
|
+
* "80" → "80" (no change)
|
|
73
|
+
* "80/tcp" → "80/tcp"
|
|
74
|
+
* "8080:80/tcp" → "80/tcp"
|
|
75
|
+
*/
|
|
76
|
+
function stripStringPort(port: string): string {
|
|
77
|
+
// Split off protocol if present (e.g. "/tcp", "/udp")
|
|
78
|
+
const protocolIdx = port.lastIndexOf("/");
|
|
79
|
+
let protocol = "";
|
|
80
|
+
let portSpec = port;
|
|
81
|
+
|
|
82
|
+
if (protocolIdx > 0) {
|
|
83
|
+
protocol = port.substring(protocolIdx); // includes the "/"
|
|
84
|
+
portSpec = port.substring(0, protocolIdx);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Split by ":" — formats are:
|
|
88
|
+
// "80" → container only
|
|
89
|
+
// "8080:80" → host:container
|
|
90
|
+
// "0.0.0.0:8080:80" → ip:host:container
|
|
91
|
+
const parts = portSpec.split(":");
|
|
92
|
+
|
|
93
|
+
// Take the last part as the container port
|
|
94
|
+
const containerPort = parts[parts.length - 1];
|
|
95
|
+
|
|
96
|
+
return `${containerPort}${protocol}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Strips host/published from an object port mapping.
|
|
101
|
+
*
|
|
102
|
+
* { target: 80, published: 8080 } → { target: 80 }
|
|
103
|
+
*/
|
|
104
|
+
function stripObjectPort(port: PortObject): PortObject {
|
|
105
|
+
const { published: _, host_ip: __, ...rest } = port;
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Strips local bind mounts (paths starting with `./`) from a Docker
|
|
111
|
+
* Compose YAML string.
|
|
112
|
+
*
|
|
113
|
+
* When deploying to a PaaS like Dokploy or Coolify as a raw compose
|
|
114
|
+
* stack, there is no cloned repository — so host-relative volume mounts
|
|
115
|
+
* like `./postgres/init-databases.sh:/docker-entrypoint-initdb.d/...`
|
|
116
|
+
* will fail because the file doesn't exist on the remote server.
|
|
117
|
+
*
|
|
118
|
+
* Named volumes (e.g. `redis-data:/data`) and absolute system paths
|
|
119
|
+
* (e.g. `/var/run/docker.sock`) are kept intact.
|
|
120
|
+
*/
|
|
121
|
+
export function stripLocalBindMounts(composeYaml: string): string {
|
|
122
|
+
const doc = parse(composeYaml) as ComposeFile;
|
|
123
|
+
|
|
124
|
+
if (!doc?.services) return composeYaml;
|
|
125
|
+
|
|
126
|
+
for (const [, service] of Object.entries(doc.services)) {
|
|
127
|
+
if (!service.volumes || !Array.isArray(service.volumes)) continue;
|
|
128
|
+
|
|
129
|
+
service.volumes = (service.volumes as (string | Record<string, unknown>)[]).filter((vol) => {
|
|
130
|
+
if (typeof vol === "string") {
|
|
131
|
+
// Bind mounts starting with "./" reference local files
|
|
132
|
+
return !vol.startsWith("./");
|
|
133
|
+
}
|
|
134
|
+
// Object-form: { type: "bind", source: "./..." }
|
|
135
|
+
if (typeof vol === "object" && vol !== null) {
|
|
136
|
+
const src = (vol as Record<string, unknown>).source;
|
|
137
|
+
return !(typeof src === "string" && src.startsWith("./"));
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Remove empty volumes array to keep YAML clean
|
|
143
|
+
if (service.volumes.length === 0) {
|
|
144
|
+
delete service.volumes;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return stringify(doc, { lineWidth: 200 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Strips security hardening options (`cap_drop`, `cap_add`, `security_opt`)
|
|
153
|
+
* from all services in a Docker Compose YAML string.
|
|
154
|
+
*
|
|
155
|
+
* Many Docker images (PostgreSQL, Redis, MinIO, etc.) start as root and
|
|
156
|
+
* use `gosu`/`su-exec` to drop privileges to a non-root user. This
|
|
157
|
+
* requires `SETUID`, `SETGID`, and other capabilities. The hardened
|
|
158
|
+
* compose output adds `cap_drop: ALL` + `no-new-privileges`, which
|
|
159
|
+
* prevents this user switch and causes containers to crash with:
|
|
160
|
+
* "failed switching to 'postgres': operation not permitted"
|
|
161
|
+
*
|
|
162
|
+
* PaaS platforms (Dokploy, Coolify) manage their own container security,
|
|
163
|
+
* so these options are unnecessary and should be removed.
|
|
164
|
+
*/
|
|
165
|
+
export function stripSecurityHardening(composeYaml: string): string {
|
|
166
|
+
const doc = parse(composeYaml) as ComposeFile;
|
|
167
|
+
|
|
168
|
+
if (!doc?.services) return composeYaml;
|
|
169
|
+
|
|
170
|
+
for (const [, service] of Object.entries(doc.services)) {
|
|
171
|
+
delete service.cap_drop;
|
|
172
|
+
delete service.cap_add;
|
|
173
|
+
delete service.security_opt;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return stringify(doc, { lineWidth: 200 });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Applies all PaaS-specific sanitisations to a Docker Compose YAML string:
|
|
181
|
+
* 1. Strips host port bindings (avoids "port already allocated" errors)
|
|
182
|
+
* 2. Strips local bind mounts (files don't exist on remote PaaS servers)
|
|
183
|
+
* 3. Strips security hardening (cap_drop/security_opt break user switching)
|
|
184
|
+
*/
|
|
185
|
+
export function sanitizeComposeForPaas(composeYaml: string): string {
|
|
186
|
+
return stripSecurityHardening(stripLocalBindMounts(stripHostPorts(composeYaml)));
|
|
187
|
+
}
|