mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-17 22:25:32 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebeb95cefd | |||
| 2d8dfee9f3 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 1ccd633153 | |||
| 8be1e68fe1 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| d675db10bb | |||
| 903789bfe5 | |||
| d1273e2fe9 | |||
| ede4626396 | |||
| 772b768217 | |||
| d3d8d9ec83 | |||
| f4dfb6a903 | |||
| 62f701425a | |||
| bee6918d9a | |||
| d7c152af0d | |||
| 5ff0c26954 | |||
| b4edb56132 | |||
| 9dab15ad47 | |||
| 0be822ef5b | |||
| a8e0477b70 | |||
| 5fa963fe87 | |||
| 3054cf53be | |||
| 8c2d4d5c19 | |||
| 60612b0986 | |||
| 49055a05b1 | |||
| 2221b8a99c | |||
| c77f6d889d | |||
| a5b8626345 | |||
| c6ab76fe09 | |||
| 2598a62771 |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "EnterWorktree",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: fix-issue
|
||||
description: Implement a GitHub issue with reproduction and verification
|
||||
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
|
||||
---
|
||||
|
||||
The issue number is passed as $1.
|
||||
|
||||
## Instance
|
||||
|
||||
No instance is running yet — start your own, isolated to this worktree:
|
||||
|
||||
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
|
||||
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
|
||||
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
to relaunch with `DOKPLOY_BASE_URL` exported first.
|
||||
|
||||
## Tools
|
||||
|
||||
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
|
||||
state (create a project, an app, an env var) and to verify backend behavior.
|
||||
Search for the tool you need; they are not all loaded upfront.
|
||||
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
|
||||
a user would see or click.
|
||||
|
||||
Pick by where the bug lives, not by convenience:
|
||||
|
||||
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
|
||||
The API returning correct data proves nothing here.
|
||||
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
|
||||
A green screenshot proves nothing here.
|
||||
- Unclear → do both.
|
||||
|
||||
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
|
||||
click through ten screens to create a project the API can create in one call.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `gh issue view $1` and read the full issue, including comments.
|
||||
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
|
||||
comment on the issue explaining what you tried and STOP.
|
||||
Do not implement anything.
|
||||
3. Implement the fix. Keep the change minimal and scoped to the issue.
|
||||
4. Run `pnpm test`, then re-run the same reproduction from step 2.
|
||||
5. Only if both pass: commit and run `gh pr create`. The PR description must
|
||||
include the before/after reproduction steps and reference the issue.
|
||||
|
||||
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"dokploy": {
|
||||
"type": "http",
|
||||
"url": "${DOKPLOY_BASE_URL}/api/mcp",
|
||||
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const runCommand = (command: string) =>
|
||||
spawnSync("bash", ["-c", command], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const outputLines = (stdout: string) =>
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
describe("createRestartSafeBackupCommand", () => {
|
||||
it("restarts the service and preserves the backup error status", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("preserves the backup error status when the restart also fails", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"Service restart also failed with exit code 17",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the restart error status when the backup succeeds", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("uploads only after a successful backup and service restart", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"upload",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -93,4 +93,46 @@ describe("getCreateEnvFileCommand", () => {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it("still interpolates a ${VAR} self-reference in the .env file", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = [
|
||||
"APP_URL=https://example.com",
|
||||
'ASSET_URL="${APP_URL}"',
|
||||
].join("\n");
|
||||
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
env: serviceEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const composeFile =
|
||||
"services:\n test:\n image: busybox\n environment:\n - ASSET_URL=${ASSET_URL}\n";
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"run",
|
||||
"--rm",
|
||||
"-T",
|
||||
"test",
|
||||
"sh",
|
||||
"-c",
|
||||
"printf '%s' \"$ASSET_URL\"",
|
||||
],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(out).toBe("https://example.com");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
+51
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -642,3 +643,53 @@ SPECIAL=café résumé naïve
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile (.env file escaping)", () => {
|
||||
it("keeps a well-formed ${VAR} self-reference interpolatable", () => {
|
||||
const serviceEnv = `
|
||||
APP_URL=https://example.com
|
||||
ASSET_URL=\${APP_URL}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
`APP_URL="https://example.com"`,
|
||||
`ASSET_URL="\${APP_URL}"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a ${VAR:-default} self-reference with a modifier interpolatable", () => {
|
||||
const serviceEnv = `ASSET_URL=\${APP_URL:-https://default.com}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`ASSET_URL="\${APP_URL:-https://default.com}"`]);
|
||||
});
|
||||
|
||||
it("escapes a literal dollar sign followed by a word so it isn't interpolated", () => {
|
||||
const serviceEnv = "PASSWORD=pa$word";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PASSWORD="pa\\$word"`]);
|
||||
});
|
||||
|
||||
it("escapes a trailing dollar sign", () => {
|
||||
const serviceEnv = "PRICE=100$";
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([`PRICE="100\\$"`]);
|
||||
});
|
||||
|
||||
it("still escapes double quotes and backslashes", () => {
|
||||
const serviceEnv = String.raw`MESSAGE=say "hi" and \backslash`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForFile(serviceEnv, "", "");
|
||||
|
||||
expect(resolved).toEqual([
|
||||
String.raw`MESSAGE="say \"hi\" and \\backslash"`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
|
||||
const containers = [
|
||||
{ containerId: "first-container" },
|
||||
{ containerId: "selected-container" },
|
||||
];
|
||||
|
||||
describe("resolveContainerSelection", () => {
|
||||
it("selects the first container when no container is selected", () => {
|
||||
expect(resolveContainerSelection(undefined, containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a manual selection when refreshed data contains it", () => {
|
||||
const refreshedContainers = containers.map((container) => ({
|
||||
...container,
|
||||
}));
|
||||
|
||||
expect(
|
||||
resolveContainerSelection("selected-container", refreshedContainers),
|
||||
).toBe("selected-container");
|
||||
});
|
||||
|
||||
it("falls back to the first container when the selection disappears", () => {
|
||||
expect(resolveContainerSelection("removed-container", containers)).toBe(
|
||||
"first-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the current selection while container data is loading", () => {
|
||||
expect(resolveContainerSelection("selected-container", undefined)).toBe(
|
||||
"selected-container",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the selection when no containers are available", () => {
|
||||
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { docker } from "@dokploy/server/constants";
|
||||
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 120000;
|
||||
const SERVICE_NAME = "dokploy-monitoring";
|
||||
const TEST_IMAGE = "busybox:latest";
|
||||
|
||||
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
|
||||
// the local daemon, so the legacy-container cleanup is exercised for real.
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn().mockResolvedValue({
|
||||
serverId: "test-server",
|
||||
serverType: "deploy",
|
||||
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
|
||||
metricsConfig: {
|
||||
server: {
|
||||
type: "Remote",
|
||||
port: 4500,
|
||||
token: "test-token",
|
||||
urlCallback: "http://localhost/callback",
|
||||
cronJob: "0 0 * * *",
|
||||
retentionDays: 2,
|
||||
refreshRate: 60,
|
||||
thresholds: { cpu: 0, memory: 0 },
|
||||
},
|
||||
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/settings", () => ({
|
||||
getDokployImageTag: vi.fn(() => "latest"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
pullImage: vi.fn().mockResolvedValue(undefined),
|
||||
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
|
||||
}));
|
||||
|
||||
const containerExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getContainer(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const serviceExists = async (name: string) => {
|
||||
try {
|
||||
await docker.getService(name).inspect();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const swarmTaskNames = async () => {
|
||||
const list = await docker.listContainers({ all: true });
|
||||
return list
|
||||
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
|
||||
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
|
||||
};
|
||||
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await docker.getService(SERVICE_NAME).remove();
|
||||
} catch {}
|
||||
try {
|
||||
await docker.getContainer(SERVICE_NAME).remove({ force: true });
|
||||
} catch {}
|
||||
for (const name of await swarmTaskNames()) {
|
||||
try {
|
||||
await docker.getContainer(name).remove({ force: true });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
|
||||
const createLegacyZombie = async () => {
|
||||
const container = await docker.createContainer({
|
||||
name: SERVICE_NAME,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
|
||||
],
|
||||
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
|
||||
});
|
||||
await container.start();
|
||||
};
|
||||
|
||||
const pullTestImage = async () => {
|
||||
try {
|
||||
await docker.getImage(TEST_IMAGE).inspect();
|
||||
return;
|
||||
} catch {}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
|
||||
err
|
||||
? reject(err)
|
||||
: docker.modem.followProgress(stream, (e: any) =>
|
||||
e ? reject(e) : resolve(),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// The code under test hardcodes the production name, so this suite cannot
|
||||
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
|
||||
const hasRealMonitoring = () => {
|
||||
const query = (cmd: string) => {
|
||||
try {
|
||||
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
query(
|
||||
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
|
||||
) !== "" ||
|
||||
query(
|
||||
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
|
||||
) !== ""
|
||||
);
|
||||
};
|
||||
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
await pullTestImage();
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
}, REAL_TEST_TIMEOUT);
|
||||
|
||||
it(
|
||||
"removes the legacy standalone container left behind by the swarm migration",
|
||||
async () => {
|
||||
await createLegacyZombie();
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(true);
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
|
||||
async () => {
|
||||
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
|
||||
const task = await docker.createContainer({
|
||||
name: taskName,
|
||||
Image: TEST_IMAGE,
|
||||
Cmd: ["sleep", "3600"],
|
||||
});
|
||||
await task.start();
|
||||
await createLegacyZombie();
|
||||
|
||||
await setupMonitoring("test-server");
|
||||
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
expect(await containerExists(taskName)).toBe(true);
|
||||
const inspect = await docker.getContainer(taskName).inspect();
|
||||
expect(inspect.State.Running).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"is idempotent when no legacy container exists",
|
||||
async () => {
|
||||
expect(await containerExists(SERVICE_NAME)).toBe(false);
|
||||
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"deploys the service even when removing the legacy container fails",
|
||||
async () => {
|
||||
const failingDocker = {
|
||||
getContainer: () => ({
|
||||
remove: async () => {
|
||||
const error: any = new Error("device or resource busy");
|
||||
error.statusCode = 500;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
getService: docker.getService.bind(docker),
|
||||
createService: docker.createService.bind(docker),
|
||||
};
|
||||
|
||||
const remoteDocker = await import(
|
||||
"@dokploy/server/utils/servers/remote-docker"
|
||||
);
|
||||
const spy = vi
|
||||
.spyOn(remoteDocker, "getRemoteDocker")
|
||||
.mockResolvedValue(failingDocker as any);
|
||||
|
||||
try {
|
||||
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
|
||||
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
|
||||
expect(await serviceExists(SERVICE_NAME)).toBe(true);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
compose: {
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
describe("reconnectServicesToTraefik", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findMany.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("does not execute an empty local command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not execute an empty remote command when no isolated deployments exist", async () => {
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments to the local Traefik network", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{ appName: "first-compose" },
|
||||
{ appName: "second-compose" },
|
||||
]);
|
||||
|
||||
await reconnectServicesToTraefik();
|
||||
|
||||
expect(mocks.execAsync).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsync).toHaveBeenCalledWith(
|
||||
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
|
||||
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconnects isolated deployments on a remote server", async () => {
|
||||
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
|
||||
|
||||
await reconnectServicesToTraefik("server-id");
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
|
||||
);
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readValidDirectory(
|
||||
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const availableContainers = option === "native" ? containers : services;
|
||||
|
||||
useEffect(() => {
|
||||
if (option === "native") {
|
||||
if (containers && containers?.length > 0) {
|
||||
setContainerId(containers[0]?.containerId);
|
||||
}
|
||||
} else {
|
||||
if (services && services?.length > 0) {
|
||||
setContainerId(services[0]?.containerId);
|
||||
}
|
||||
}
|
||||
}, [option, services, containers]);
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, availableContainers),
|
||||
);
|
||||
}, [availableContainers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
@@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
<Switch
|
||||
checked={option === "native"}
|
||||
onCheckedChange={(checked) => {
|
||||
setContainerId(undefined);
|
||||
setOption(checked ? "native" : "swarm");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
@@ -70,18 +71,13 @@ export const ShowDockerLogsStack = ({
|
||||
);
|
||||
|
||||
const containers = data?.filter((container) => container.containerId);
|
||||
const availableContainers = option === "native" ? containers : services;
|
||||
|
||||
useEffect(() => {
|
||||
if (option === "native") {
|
||||
if (containers && containers?.length > 0) {
|
||||
setContainerId(containers[0]?.containerId);
|
||||
}
|
||||
} else {
|
||||
if (services && services?.length > 0) {
|
||||
setContainerId(services[0]?.containerId);
|
||||
}
|
||||
}
|
||||
}, [option, services, containers]);
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, availableContainers),
|
||||
);
|
||||
}, [availableContainers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
@@ -106,6 +102,7 @@ export const ShowDockerLogsStack = ({
|
||||
<Switch
|
||||
checked={option === "native"}
|
||||
onCheckedChange={(checked) => {
|
||||
setContainerId(undefined);
|
||||
setOption(checked ? "native" : "swarm");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,28 @@ export interface LogLine {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ContainerOption {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const resolveContainerSelection = (
|
||||
currentContainerId: string | undefined,
|
||||
containers: readonly ContainerOption[] | undefined,
|
||||
) => {
|
||||
if (!containers) {
|
||||
return currentContainerId;
|
||||
}
|
||||
|
||||
if (
|
||||
currentContainerId &&
|
||||
containers.some(({ containerId }) => containerId === currentContainerId)
|
||||
) {
|
||||
return currentContainerId;
|
||||
}
|
||||
|
||||
return containers[0]?.containerId;
|
||||
};
|
||||
|
||||
interface LogStyle {
|
||||
type: LogType;
|
||||
variant: LogVariant;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.1",
|
||||
"version": "v0.30.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
|
||||
),
|
||||
});
|
||||
|
||||
if (!composeResult) {
|
||||
if (composeResult.length === 0) {
|
||||
return;
|
||||
}
|
||||
let commands = "";
|
||||
|
||||
@@ -21,11 +21,31 @@ const getMonitoringImage = () => {
|
||||
return imageName;
|
||||
};
|
||||
|
||||
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
|
||||
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
|
||||
const removeLegacyContainer = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
) => {
|
||||
try {
|
||||
await docker.getContainer(serviceName).remove({ force: true });
|
||||
console.log("Removed legacy monitoring container ✅");
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
console.warn(
|
||||
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deployMonitoringService = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
serviceName: string,
|
||||
settings: CreateServiceOptions,
|
||||
) => {
|
||||
await removeLegacyContainer(docker, serviceName);
|
||||
|
||||
try {
|
||||
const service = docker.getService(serviceName);
|
||||
const inspect = await service.inspect();
|
||||
|
||||
@@ -86,6 +86,7 @@ export const serverSetup = async (
|
||||
...server.metricsConfig.server,
|
||||
token: token,
|
||||
urlCallback: urlCallback,
|
||||
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
|
||||
},
|
||||
containers: server.metricsConfig.containers,
|
||||
},
|
||||
|
||||
@@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$");
|
||||
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^}]*)?\})/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,6 +9,43 @@ import {
|
||||
normalizeS3Path,
|
||||
} from "../backups/utils";
|
||||
|
||||
interface RestartSafeBackupCommandOptions {
|
||||
stopCommand: string;
|
||||
backupCommand: string;
|
||||
startCommand: string;
|
||||
uploadCommand: string;
|
||||
}
|
||||
|
||||
export const createRestartSafeBackupCommand = ({
|
||||
stopCommand,
|
||||
backupCommand,
|
||||
startCommand,
|
||||
uploadCommand,
|
||||
}: RestartSafeBackupCommandOptions) => `
|
||||
${stopCommand}
|
||||
set +e
|
||||
(
|
||||
${backupCommand}
|
||||
)
|
||||
DOKPLOY_VOLUME_BACKUP_STATUS=$?
|
||||
(
|
||||
set -e
|
||||
${startCommand}
|
||||
)
|
||||
DOKPLOY_VOLUME_RESTART_STATUS=$?
|
||||
set -e
|
||||
if [ "$DOKPLOY_VOLUME_BACKUP_STATUS" -ne 0 ]; then
|
||||
if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then
|
||||
echo "Service restart also failed with exit code $DOKPLOY_VOLUME_RESTART_STATUS"
|
||||
fi
|
||||
exit "$DOKPLOY_VOLUME_BACKUP_STATUS"
|
||||
fi
|
||||
if [ "$DOKPLOY_VOLUME_RESTART_STATUS" -ne 0 ]; then
|
||||
exit "$DOKPLOY_VOLUME_RESTART_STATUS"
|
||||
fi
|
||||
${uploadCommand}
|
||||
`;
|
||||
|
||||
export const getVolumeServiceAppName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
@@ -117,16 +154,20 @@ export const backupVolume = async (
|
||||
);
|
||||
|
||||
if (serviceType === "application") {
|
||||
return lockWrapper(`
|
||||
echo "Stopping application to 0 replicas"
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service update --replicas=0 ${volumeBackup.application?.appName}
|
||||
${backupCommand}
|
||||
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
|
||||
${uploadCommand}
|
||||
`);
|
||||
return lockWrapper(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: `
|
||||
echo "Stopping application to 0 replicas"
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service update --replicas=0 ${volumeBackup.application?.appName}`,
|
||||
backupCommand,
|
||||
startCommand: `
|
||||
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}`,
|
||||
uploadCommand,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (serviceType === "compose") {
|
||||
const compose = await findComposeById(
|
||||
@@ -158,11 +199,13 @@ export const backupVolume = async (
|
||||
echo "Compose container started"
|
||||
`;
|
||||
}
|
||||
return lockWrapper(`
|
||||
${stopCommand}
|
||||
${backupCommand}
|
||||
${startCommand}
|
||||
${uploadCommand}
|
||||
`);
|
||||
return lockWrapper(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand,
|
||||
backupCommand,
|
||||
startCommand,
|
||||
uploadCommand,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ export const readValidDirectory = (
|
||||
directory: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
|
||||
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat)
|
||||
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ENV_FILE="$WORKTREE_PATH/apps/dokploy/.env"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FREE_PORT=$(node "$CLAUDE_PROJECT_DIR/scripts/find-free-port.mjs")
|
||||
|
||||
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" "$ENV_FILE"
|
||||
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" "$ENV_FILE"
|
||||
rm -f "$ENV_FILE.bak"
|
||||
|
||||
echo "Worktree $WORKTREE_PATH -> dokploy dev PORT=$FREE_PORT"
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createServer } from "node:net";
|
||||
|
||||
function isFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findFreePort(start) {
|
||||
let port = start;
|
||||
while (!(await isFree(port))) {
|
||||
port++;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
const start = Number.parseInt(
|
||||
process.argv[2] || process.env.PORT || "3000",
|
||||
10,
|
||||
);
|
||||
const port = await findFreePort(start);
|
||||
process.stdout.write(String(port));
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PAYLOAD=$(cat)
|
||||
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
|
||||
|
||||
if [ -z "$WORKTREE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Turbopack refuses to resolve through anything outside its detected
|
||||
# workspace root, so a symlinked node_modules (whole dir or per-entry)
|
||||
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
|
||||
# since pnpm's global content-addressable store is already warm, this
|
||||
# only links locally — no network fetch, a few seconds.
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
|
||||
# --show-toplevel would return the CURRENT worktree's own root if this is
|
||||
# run from inside one (e.g. another agent's worktree) instead of the main
|
||||
# checkout. --git-common-dir always points at the shared .git regardless of
|
||||
# which worktree you're standing in.
|
||||
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
|
||||
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
|
||||
BRANCH="worktree-$NAME"
|
||||
|
||||
if [ -e "$WORKTREE_PATH" ]; then
|
||||
echo "Worktree already exists: $WORKTREE_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_ROOT" fetch origin canary --quiet
|
||||
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
|
||||
|
||||
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
|
||||
# `git worktree add` won't check out on its own - copy them in.
|
||||
while IFS= read -r pattern; do
|
||||
[ -z "$pattern" ] && continue
|
||||
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
|
||||
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
|
||||
rel="${src#"$REPO_ROOT"/}"
|
||||
dest="$WORKTREE_PATH/$rel"
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
|
||||
cd "$WORKTREE_PATH"
|
||||
pnpm install --prefer-offline >&2
|
||||
|
||||
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
|
||||
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
|
||||
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
|
||||
rm -f apps/dokploy/.env.bak
|
||||
|
||||
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
|
||||
echo $! > "$WORKTREE_PATH/dev-server.pid"
|
||||
|
||||
BASE_URL="http://localhost:$FREE_PORT"
|
||||
for _ in $(seq 1 30); do
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
|
||||
if [ "$CODE" != "000" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "export WORKTREE_PATH=$WORKTREE_PATH"
|
||||
echo "export DOKPLOY_BASE_URL=$BASE_URL"
|
||||
echo "export PORT=$FREE_PORT"
|
||||
Reference in New Issue
Block a user