mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
Merge branch 'canary' into feat/dns-records-management
This commit is contained in:
@@ -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,2 @@
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,6 @@
|
||||
## Code style
|
||||
- Don't write comments that restate what the code already says.
|
||||
- Comment only the "why" when something isn't obvious: workarounds,
|
||||
counterintuitive decisions, constraints from an external API.
|
||||
- No section-divider comments like `// --- Helpers ---`.
|
||||
- Don't leave comments describing the change you just made.
|
||||
+9
-1
@@ -120,12 +120,20 @@ pnpm run docker:push
|
||||
|
||||
## Password Reset
|
||||
|
||||
In the case you lost your password, you can reset it using the following command
|
||||
In the case you lost your password, you can reset the owner's password using the following command
|
||||
|
||||
```bash
|
||||
pnpm run reset-password
|
||||
```
|
||||
|
||||
To reset the password of a specific user instead, pass their email as an argument
|
||||
|
||||
```bash
|
||||
pnpm run reset-password -- user@example.com
|
||||
```
|
||||
|
||||
Both commands print the new randomly generated password to the console.
|
||||
|
||||
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --project-directory", () => {
|
||||
it("pins --project-directory to the code dir when composePath is nested", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
|
||||
});
|
||||
|
||||
it("omits --project-directory when no projectPath is passed", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
});
|
||||
|
||||
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd.startsWith("stack deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f docker-compose.yml");
|
||||
});
|
||||
|
||||
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./backend/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd).toContain("-f ./backend/docker-compose.yml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose createCommand --env-file", () => {
|
||||
it("points --env-file at the generated .env next to a nested compose file", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file deploy/.env");
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits --env-file when createEnvFile is disabled", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
|
||||
it("uses the code-root .env for raw sourceType", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
sourceType: "raw",
|
||||
composePath: "docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file .env");
|
||||
});
|
||||
|
||||
it("does not add --env-file to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ const cases: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: "https://example.com",
|
||||
DB_HOST: "localhost",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
@@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: '"${APP_URL}"',
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
|
||||
@@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("installs Railpack through sudo for non-root users", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain(
|
||||
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
|
||||
);
|
||||
expect(command).toContain("sudo -n true 2>/dev/null");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
|
||||
@@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should deploy when a changed file remains after exclusions", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should combine positive and negated watch paths", () => {
|
||||
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
|
||||
+127
@@ -20,6 +20,7 @@ import {
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
@@ -616,3 +617,129 @@ describe("scaleway client", () => {
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase client", () => {
|
||||
const config = {
|
||||
providerType: "phase" as const,
|
||||
token: "phase-rest-token",
|
||||
appId: "app-123",
|
||||
env: "Production",
|
||||
path: "/",
|
||||
apiUrl: "https://api.phase.dev",
|
||||
};
|
||||
|
||||
it("fetches secrets by key and sends ServiceAccount auth", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "postgres://real", path: "/" },
|
||||
{ key: "API_KEY", value: "key-123", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer ServiceAccount phase-rest-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a requested secret is missing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
|
||||
'secret "MISSING" not found in environment "Production"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ error: "unauthorized" }, false, 401),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
|
||||
"authentication failed (status 401: unauthorized)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects apps without SSE enabled during testConnection", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(phaseClient.testConnection(config)).rejects.toThrow(
|
||||
"enable Server-side Encryption (SSE)",
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests connection against the app then secrets listing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: true,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
|
||||
await phaseClient.testConnection(config);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from the configured path", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "x", path: "/" },
|
||||
{ key: "API_KEY", value: "y", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const names = await phaseClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["DB_URL", "API_KEY"]);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a phase provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "phase-prod",
|
||||
providerType: "phase",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied server.terminal", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied registry.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (role: string) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
accessedServers: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn = mockMemberData("deployer");
|
||||
let rolesToReturn: { permission: string }[] = [];
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
const { checkPermission, resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const withPermissions = (permissions: Record<string, string[]>) => {
|
||||
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
memberToReturn = mockMemberData("deployer");
|
||||
rolesToReturn = [];
|
||||
});
|
||||
|
||||
describe("server.terminal on custom roles", () => {
|
||||
it("a role with server.read alone cannot open a terminal", async () => {
|
||||
withPermissions({ server: ["read"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.read).toBe(true);
|
||||
expect(perms.server.terminal).toBe(false);
|
||||
});
|
||||
|
||||
it("a role with server.terminal can open a terminal", async () => {
|
||||
withPermissions({ server: ["read", "terminal"] });
|
||||
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("owner and admin keep terminal access", async () => {
|
||||
for (const role of ["owner", "admin"]) {
|
||||
memberToReturn = mockMemberData(role);
|
||||
await expect(
|
||||
checkPermission(ctx, { server: ["terminal"] }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.server.terminal).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Swarm keeps converging a service for a bit after it's created (scheduling
|
||||
// tasks, resolving endpoints), which bumps Version.Index on its own. Calling
|
||||
// setupMonitoring again before that settles races that internal bump, so wait
|
||||
// for two consecutive reads to agree before treating the service as stable.
|
||||
const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastIndex: string | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const inspect = await docker.getService(name).inspect();
|
||||
if (inspect.Version.Index === lastIndex) return;
|
||||
lastIndex = inspect.Version.Index;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
};
|
||||
|
||||
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 waitForServiceConvergence(SERVICE_NAME);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
};
|
||||
});
|
||||
|
||||
describe("writeAppTraefikConfig", () => {
|
||||
let cwd: string;
|
||||
let dynamicPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
|
||||
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
|
||||
fs.mkdirSync(dynamicPath, { recursive: true });
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Regression test for #5189: Traefik's file provider rejects a standalone
|
||||
// `routers: {}` / `services: {}` map and aborts its watcher for every
|
||||
// dynamic config once it hits one, so an app with no domains must never
|
||||
// get an on-disk config file at all.
|
||||
it("removes the file instead of writing empty routers/services", async () => {
|
||||
const appName = "no-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
fs.writeFileSync(configPath, "stale content", "utf8");
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("writes the file when routers/services are present", async () => {
|
||||
const appName = "with-domain-app";
|
||||
const configPath = path.join(dynamicPath, `${appName}.yml`);
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`${appName}-router-1`]: {
|
||||
rule: "Host(`x`)",
|
||||
service: `${appName}-service`,
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
appName,
|
||||
);
|
||||
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes the remote file instead of writing empty routers/services", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{ http: { routers: {}, services: {} } },
|
||||
"no-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^rm -f /);
|
||||
expect(command).toContain("no-domain-app.yml");
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
"with-domain-app-router-1": {
|
||||
rule: "Host(`x`)",
|
||||
service: "with-domain-app-service",
|
||||
},
|
||||
},
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
"with-domain-app",
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^echo /);
|
||||
});
|
||||
});
|
||||
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
|
||||
});
|
||||
|
||||
it("gates a remote server terminal on server access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
// role lookup must not be needed for the remote path
|
||||
// the remote path must never fall through to the owner/admin local branch
|
||||
expect(mockFindMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies a remote server terminal without the server.terminal permission", async () => {
|
||||
// Reaching a server (to deploy on it) must not imply a root shell on it.
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(false);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
|
||||
expect(mockHasPermission).toHaveBeenCalledWith(
|
||||
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
|
||||
{ server: ["terminal"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a remote server terminal with the server.terminal permission", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not check permissions for a server the caller cannot access", async () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
|
||||
expect(mockHasPermission).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,7 +342,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
<span className="wrap-anywhere text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
try {
|
||||
const result = await validateDomain({
|
||||
domain: host,
|
||||
serverIp:
|
||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||
serverId: application?.serverId ?? undefined,
|
||||
});
|
||||
|
||||
setValidationStates((prev) => ({
|
||||
|
||||
@@ -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 =
|
||||
@@ -105,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-row justify-between items-center gap-2">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
|
||||
<Label>Select a container to view logs</Label>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -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,
|
||||
@@ -54,6 +55,7 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "swarm",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -66,26 +68,18 @@ export const ShowDockerLogsStack = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName && option === "native",
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
const containers = data?.filter((container) => container.containerId);
|
||||
const availableContainers = option === "native" ? containers : services;
|
||||
|
||||
useEffect(() => {
|
||||
const currentContainers = option === "native" ? containers : services;
|
||||
|
||||
if (currentContainers) {
|
||||
const nextContainerId = currentContainers.some(
|
||||
(container) => container.containerId === containerId,
|
||||
)
|
||||
? containerId
|
||||
: currentContainers[0]?.containerId;
|
||||
|
||||
if (nextContainerId !== containerId) {
|
||||
setContainerId(nextContainerId);
|
||||
}
|
||||
}
|
||||
}, [option, services, containers, containerId]);
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, availableContainers),
|
||||
);
|
||||
}, [availableContainers]);
|
||||
|
||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||
const containersLength =
|
||||
|
||||
@@ -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,
|
||||
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
setContainerId((currentContainerId) =>
|
||||
resolveContainerSelection(currentContainerId, data),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
onClick={handlePauseResume}
|
||||
title={isPaused ? "Resume logs" : "Pause logs"}
|
||||
>
|
||||
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
title="Copy logs to clipboard"
|
||||
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 sm:w-auto w-full"
|
||||
className="h-9 w-full sm:w-auto"
|
||||
onClick={handleDownload}
|
||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||
title="Download logs as text file"
|
||||
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
>
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||
|
||||
@@ -64,7 +64,9 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
|
||||
const tooltip = (color: string, timestamp: string | null) => {
|
||||
const square = (
|
||||
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
|
||||
<div
|
||||
className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)}
|
||||
/>
|
||||
);
|
||||
return timestamp ? (
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
@@ -88,7 +90,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
||||
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
|
||||
type === "error"
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: type === "warning"
|
||||
@@ -104,7 +106,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||
{tooltip(color, rawTimestamp)}
|
||||
{!noTimestamp && (
|
||||
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
|
||||
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { formatMb } from "@dokploy/server/monitoring/units";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appName) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log(e.reason);
|
||||
if (e.reason) {
|
||||
toast.error(e.reason);
|
||||
}
|
||||
};
|
||||
|
||||
return () => ws.close();
|
||||
|
||||
@@ -19,6 +19,11 @@ interface NetworkChartProps {
|
||||
data: any[];
|
||||
}
|
||||
|
||||
function formatNetworkGB(valueInMB: number) {
|
||||
if (Number.isNaN(valueInMB)) return "0";
|
||||
return (valueInMB / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
networkIn: {
|
||||
label: "Network In",
|
||||
@@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
<CardHeader className="border-b py-5">
|
||||
<CardTitle>Network</CardTitle>
|
||||
<CardDescription>
|
||||
Network Traffic: ↑ {latestData.networkOut} KB/s ↓{" "}
|
||||
{latestData.networkIn} KB/s
|
||||
Network Total: ↑ {formatNetworkGB(latestData.networkOut)} GB ↓{" "}
|
||||
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
|
||||
@@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => formatTimestamp(value)}
|
||||
/>
|
||||
<YAxis tickFormatter={(value) => `${value} KB/s`} />
|
||||
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload, label }) => {
|
||||
@@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
|
||||
Network
|
||||
</span>
|
||||
<span className="font-bold">
|
||||
↑ {data.networkOut} KB/s
|
||||
<br />↓ {data.networkIn} KB/s
|
||||
↑ {formatNetworkGB(data.networkOut)} GB
|
||||
<br />↓ {formatNetworkGB(data.networkIn)} GB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
const importMutation = api.network.import.useMutation();
|
||||
const removeMutation = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
const resyncMutation = api.network.resync.useMutation();
|
||||
|
||||
const toggleSelected = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onResync = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await resyncMutation.mutateAsync({ networkId });
|
||||
toast.success(`Network "${name}" updated from Docker`);
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error updating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data?.changed.length && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Changed ({data.changed.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
These networks were deleted and recreated in Docker under
|
||||
the same name — attributes in Dokploy are outdated.
|
||||
</span>
|
||||
{data.changed.map((changed) => (
|
||||
<div
|
||||
key={changed.networkId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">{changed.name}</span>
|
||||
{changed.driver && (
|
||||
<Badge variant="outline">{changed.driver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={resyncMutation.isPending}
|
||||
onClick={() =>
|
||||
onResync(changed.networkId, changed.name)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!data?.missing.length && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
},
|
||||
{
|
||||
enabled: !!organizationId,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isPending } = organizationId
|
||||
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
Download,
|
||||
Globe,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
Server,
|
||||
TrendingUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -100,7 +102,12 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: statsLogs } = api.settings.readStatsLogs.useQuery(
|
||||
const {
|
||||
data: statsLogs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
sort: sorting[0],
|
||||
page: pagination,
|
||||
@@ -273,7 +280,18 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{statsLogs?.data.length === 0 && (
|
||||
{isLoading ? (
|
||||
<div className="w-full flex gap-4 items-center justify-center h-[55vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading requests...</span>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="w-full flex items-center justify-center h-[55vh]">
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error?.message}
|
||||
</AlertBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
|
||||
<span className="text-muted-foreground text-lg font-medium">
|
||||
No results.
|
||||
|
||||
@@ -446,6 +446,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
serverUrl: notification.gotify?.serverUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "ntfy") {
|
||||
form.reset({
|
||||
@@ -681,6 +682,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
decoration: data.decoration,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
gotifyId: notification?.gotifyId || "",
|
||||
});
|
||||
@@ -698,6 +700,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
|
||||
@@ -250,29 +250,30 @@ export const ShowServers = () => {
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
|
||||
export const ShowUsers = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isPending, refetch } = api.user.all.useQuery();
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: hasValidLicense } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
@@ -237,6 +237,7 @@ export const ShowUsers = () => {
|
||||
title="Delete User"
|
||||
description="Are you sure you want to delete this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
userId: member.user.id,
|
||||
@@ -269,6 +270,7 @@ export const ShowUsers = () => {
|
||||
title="Unlink User"
|
||||
description="Are you sure you want to unlink this user?"
|
||||
type="destructive"
|
||||
disabled={isRemoving}
|
||||
onClick={async () => {
|
||||
if (!isCloud) {
|
||||
const orgCount =
|
||||
|
||||
@@ -43,6 +43,7 @@ const providerLabels = {
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -63,6 +64,7 @@ const VaultProviderSchema = z
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
"phase",
|
||||
]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
@@ -89,6 +91,11 @@ const VaultProviderSchema = z
|
||||
scalewayProjectId: z.string(),
|
||||
scalewaySecretKey: z.string(),
|
||||
scalewayApiUrl: z.string(),
|
||||
phaseToken: z.string(),
|
||||
phaseAppId: z.string(),
|
||||
phaseEnv: z.string(),
|
||||
phasePath: z.string(),
|
||||
phaseApiUrl: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -161,6 +168,17 @@ const VaultProviderSchema = z
|
||||
path: ["siteUrl"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "phase" &&
|
||||
data.phaseApiUrl &&
|
||||
!isValidUrl(data.phaseApiUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://api.phase.dev)",
|
||||
path: ["phaseApiUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const required: Partial<
|
||||
Record<ProviderType, [keyof typeof data, string][]>
|
||||
@@ -194,6 +212,11 @@ const VaultProviderSchema = z
|
||||
["scalewayProjectId", "Project ID is required"],
|
||||
["scalewaySecretKey", "Secret Key is required"],
|
||||
],
|
||||
phase: [
|
||||
["phaseToken", "Service Account REST API token is required"],
|
||||
["phaseAppId", "App ID is required"],
|
||||
["phaseEnv", "Environment is required"],
|
||||
],
|
||||
};
|
||||
|
||||
for (const [field, message] of required[data.providerType] ?? []) {
|
||||
@@ -254,6 +277,11 @@ const defaultValues: VaultProviderForm = {
|
||||
scalewayProjectId: "",
|
||||
scalewaySecretKey: "",
|
||||
scalewayApiUrl: "https://api.scaleway.com",
|
||||
phaseToken: "",
|
||||
phaseAppId: "",
|
||||
phaseEnv: "",
|
||||
phasePath: "/",
|
||||
phaseApiUrl: "https://api.phase.dev",
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
@@ -308,6 +336,15 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
secretKey: data.scalewaySecretKey,
|
||||
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
|
||||
};
|
||||
case "phase":
|
||||
return {
|
||||
providerType: "phase" as const,
|
||||
token: data.phaseToken,
|
||||
appId: data.phaseAppId,
|
||||
env: data.phaseEnv,
|
||||
path: data.phasePath || "/",
|
||||
apiUrl: data.phaseApiUrl || "https://api.phase.dev",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -429,6 +466,13 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
scalewaySecretKey: provider.config.secretKey,
|
||||
scalewayApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
...(provider.config.providerType === "phase" && {
|
||||
phaseToken: provider.config.token,
|
||||
phaseAppId: provider.config.appId,
|
||||
phaseEnv: provider.config.env,
|
||||
phasePath: provider.config.path,
|
||||
phaseApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
});
|
||||
} else if (!vaultProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -996,6 +1040,94 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "phase" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Account REST API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use the REST API token from a Service Account — not the
|
||||
CLI/SDK <code>pss_*</code> token. The Phase App must
|
||||
have Server-side Encryption (SSE) enabled.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseAppId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseEnv"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Production" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phasePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phaseApiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://api.phase.dev" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Self-hosted Phase defaults to{" "}
|
||||
<code>{"${HTTP_PROTOCOL}${HOST}/service/public"}</code>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.SECRET_KEY}}"}</code>
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<FormDescription>
|
||||
|
||||
@@ -21,6 +21,7 @@ const providerLabels: Record<string, string> = {
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
phase: "Phase",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@@ -582,6 +582,28 @@ export const ScalewayIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PhaseIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 2L3.5 6.5v11L12 22l8.5-4.5v-11L12 2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 2v20M3.5 6.5L12 11l8.5-4.5M3.5 17.5L12 13l8.5 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
@@ -589,4 +611,5 @@ export const vaultProviderIcons = {
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
phase: PhaseIcon,
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import Page from "./side";
|
||||
@@ -8,9 +10,11 @@ interface Props {
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children }: Props) => {
|
||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
|
||||
enabled: isCloud === true,
|
||||
refetchOnWindowFocus: false,
|
||||
@@ -22,6 +26,13 @@ export const DashboardLayout = ({ children }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaName && (
|
||||
<Head>
|
||||
<title>
|
||||
{metaName} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
|
||||
@@ -173,7 +173,7 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
vaultProvider: {
|
||||
label: "Secrets Providers",
|
||||
description:
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway, Phase) and where their secrets can be referenced",
|
||||
},
|
||||
dnsProvider: {
|
||||
label: "DNS Providers",
|
||||
@@ -328,6 +328,10 @@ const ACTION_META: Record<
|
||||
label: "Delete",
|
||||
description: "Remove servers from the organization",
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
description: "Open an SSH root shell on remote servers",
|
||||
},
|
||||
},
|
||||
registry: {
|
||||
read: { label: "Read", description: "View configured Docker registries" },
|
||||
@@ -569,7 +573,7 @@ const ROLE_PRESETS: {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "delete"],
|
||||
backup: ["read", "create", "delete", "restore"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
import { useRouter } from "next/router";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
@@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
|
||||
* version only, and translates tracking query params appended by the
|
||||
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
|
||||
* Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel
|
||||
* product analytics on the cloud version only, and translates tracking
|
||||
* query params appended by the auth/billing flows (?signup=...,
|
||||
* ?subscription=new) into dataLayer events.
|
||||
*/
|
||||
export const Analytics = () => {
|
||||
const router = useRouter();
|
||||
@@ -40,6 +42,14 @@ export const Analytics = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
apiUrl="https://openpanel.dokploy.com/api"
|
||||
clientId="bf5a178b-7f28-4461-bf47-d63feff15922"
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
trackAttributes={true}
|
||||
globalProperties={{ site: "app" }}
|
||||
/>
|
||||
<Script id="analytics-init" strategy="afterInteractive">
|
||||
{`
|
||||
window.hsConversationsSettings = { loadImmediately: false };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "dockerId" text;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Grant the new "server.terminal" permission to existing custom roles that already have
|
||||
-- "server.read", which is the permission that surfaces the terminal in the UI today.
|
||||
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
|
||||
-- UI, so from now on they are denied at the websocket too.
|
||||
UPDATE "organization_role" AS r
|
||||
SET "permission" = jsonb_set(
|
||||
r."permission"::jsonb,
|
||||
'{server}',
|
||||
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
|
||||
)::text
|
||||
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
|
||||
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
|
||||
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'phase';
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1303,6 +1303,27 @@
|
||||
"when": 1786526512677,
|
||||
"tag": "0185_needy_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 186,
|
||||
"version": "7",
|
||||
"when": 1787813846067,
|
||||
"tag": "0186_tearful_dragon_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 187,
|
||||
"version": "7",
|
||||
"when": 1787937580323,
|
||||
"tag": "0187_grant_terminal_permission_to_read_roles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 188,
|
||||
"version": "7",
|
||||
"when": 1788249519837,
|
||||
"tag": "0188_volatile_piledriver",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.2",
|
||||
"version": "v0.30.3",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -67,6 +67,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/webhooks": "^13.9.0",
|
||||
"@openpanel/nextjs": "^1.5.1",
|
||||
"@react-email/components": "^1.0.12",
|
||||
"@stepperize/react": "4.0.1",
|
||||
"@stripe/stripe-js": "4.8.0",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import {
|
||||
OPENAPI_MAX_JSON_BODY_SIZE,
|
||||
OPENAPI_MAX_UPLOAD_SIZE,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
@@ -12,10 +16,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
|
||||
const contentLength = Number(req.headers["content-length"]);
|
||||
const isMultipart = req.headers["content-type"]?.startsWith(
|
||||
"multipart/form-data",
|
||||
);
|
||||
|
||||
if (isMultipart && !Number.isFinite(contentLength)) {
|
||||
res.status(411).json({ message: "Content-Length required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = isMultipart
|
||||
? OPENAPI_MAX_UPLOAD_SIZE
|
||||
: OPENAPI_MAX_JSON_BODY_SIZE;
|
||||
|
||||
if (Number.isFinite(contentLength) && contentLength > limit) {
|
||||
res.status(413).json({ message: "Payload too large" });
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
|
||||
onError:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ({ path, error }: { path: string | undefined; error: Error }) => {
|
||||
@@ -28,3 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,30 +1,54 @@
|
||||
import { findOwner, generateRandomPassword } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { account } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { account, user } from "@dokploy/server/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
|
||||
const randomPassword = await generateRandomPassword();
|
||||
|
||||
const result = await findOwner();
|
||||
let userId: string;
|
||||
|
||||
if (email) {
|
||||
const foundUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
console.log(`User not found for email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
userId = foundUser.id;
|
||||
} else {
|
||||
const owner = await findOwner();
|
||||
userId = owner.userId;
|
||||
}
|
||||
|
||||
const update = await db
|
||||
.update(account)
|
||||
.set({
|
||||
password: randomPassword.hashedPassword,
|
||||
})
|
||||
.where(eq(account.userId, result.userId));
|
||||
.where(
|
||||
and(eq(account.userId, userId), eq(account.providerId, "credential")),
|
||||
);
|
||||
|
||||
if (update) {
|
||||
if (update.count > 0) {
|
||||
console.log("Password reset successful");
|
||||
console.log("New password: ", randomPassword.randomPassword);
|
||||
} else {
|
||||
console.log("Password reset failed");
|
||||
console.log(
|
||||
"Password reset failed: no credential account found for this user",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.log("Error resetting password", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addDomainToCompose,
|
||||
clearOldDeployments,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
|
||||
import {
|
||||
@@ -547,7 +549,12 @@ export const composeRouter = createTRPCRouter({
|
||||
service: ["create"],
|
||||
});
|
||||
const compose = await findComposeById(input.composeId);
|
||||
const command = createCommand(compose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(
|
||||
compose,
|
||||
compose.mounts.length > 0 ? projectPath : undefined,
|
||||
);
|
||||
return `docker ${command}`;
|
||||
}),
|
||||
refreshToken: protectedProcedure
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
getServerIpCandidates,
|
||||
getWebServerSettings,
|
||||
manageDomain,
|
||||
removeDomain,
|
||||
@@ -248,10 +249,21 @@ export const domainRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
domain: z.string(),
|
||||
serverIp: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
return validateDomain(input.domain, input.serverIp);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expectedIps = await getServerIpCandidates(input.serverId);
|
||||
return validateDomain(input.domain, expectedIps);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
inspectNetwork,
|
||||
recreateNetwork,
|
||||
removeNetwork,
|
||||
resyncNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
|
||||
return recreated;
|
||||
}),
|
||||
|
||||
resync: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
const resynced = await resyncNetwork(
|
||||
input.networkId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "network",
|
||||
resourceId: resynced.networkId,
|
||||
resourceName: resynced.name,
|
||||
});
|
||||
return resynced;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { asc, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const scheduleRouter = createTRPCRouter({
|
||||
@@ -130,7 +130,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
|
||||
if (IS_CLOUD) {
|
||||
if (updatedSchedule?.enabled) {
|
||||
schedule({
|
||||
await updateJob({
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
@@ -141,6 +141,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
cronSchedule: updatedSchedule.cronExpression,
|
||||
scheduleId: updatedSchedule.scheduleId,
|
||||
type: "schedule",
|
||||
timezone: updatedSchedule.timezone,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -185,6 +186,7 @@ export const scheduleRouter = createTRPCRouter({
|
||||
cronSchedule: scheduleItem.cronExpression,
|
||||
scheduleId: scheduleItem.scheduleId,
|
||||
type: "schedule",
|
||||
timezone: scheduleItem.timezone,
|
||||
});
|
||||
} else {
|
||||
removeScheduleJob(scheduleItem.scheduleId);
|
||||
|
||||
@@ -62,7 +62,8 @@ export const canAccessDockerOverWss = async (
|
||||
|
||||
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
|
||||
// host terminal is a root shell on the control-plane host, so it is restricted
|
||||
// to owner/admin. A remote server terminal is gated on server access.
|
||||
// to owner/admin. A remote server terminal needs server access plus
|
||||
// server.terminal.
|
||||
export const canAccessTerminalOverWss = async (
|
||||
user: WssUser,
|
||||
session: WssSession,
|
||||
@@ -75,7 +76,11 @@ export const canAccessTerminalOverWss = async (
|
||||
userId: user.id,
|
||||
activeOrganizationId: session.activeOrganizationId,
|
||||
});
|
||||
return accessible.has(serverId);
|
||||
if (!accessible.has(serverId)) return false;
|
||||
|
||||
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
|
||||
server: ["terminal"],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -182,6 +182,10 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
ptyProcess.onData((data) => {
|
||||
ws.send(data);
|
||||
});
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ws.send(`\nContainer closed with code: ${exitCode}\n`);
|
||||
ws.close();
|
||||
});
|
||||
ws.on("close", () => {
|
||||
ptyProcess.kill();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,57 @@ import {
|
||||
recordAdvancedStats,
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { canAccessDockerOverWss } from "./authorize";
|
||||
|
||||
type AppType = "application" | "stack" | "docker-compose";
|
||||
|
||||
// Swarm task names are "<service>.<slot>.<taskId>"; the manager's local
|
||||
// `docker ps` only sees containers scheduled on this host, so a task running
|
||||
// on another node looks identical to a stopped one. `docker service ps` is
|
||||
// swarm-aggregated and answers from the manager regardless of which node the
|
||||
// task landed on, so we use it here to tell the two cases apart.
|
||||
const findRemoteSwarmNode = async (
|
||||
appName: string,
|
||||
appType: AppType,
|
||||
): Promise<string | null> => {
|
||||
if (appType === "docker-compose") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
|
||||
// never the taskId, so we match on the task ID instead — the last segment
|
||||
// of the stack task name — rather than trying to reconstruct the full name.
|
||||
const [serviceName, taskId] =
|
||||
appType === "stack"
|
||||
? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()]
|
||||
: [appName, null];
|
||||
|
||||
if (!serviceName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`,
|
||||
);
|
||||
|
||||
for (const line of stdout.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const task = JSON.parse(line);
|
||||
const isMatch = taskId ? task.ID === taskId : true;
|
||||
if (isMatch && task.CurrentState?.startsWith("Running")) {
|
||||
return task.Node;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not a swarm service (or the swarm CLI isn't available) — fall back to "not running".
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupDockerStatsMonitoringSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
) => {
|
||||
@@ -98,7 +146,13 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
|
||||
const container = containers[0];
|
||||
if (!container || container?.State !== "running") {
|
||||
ws.close(4000, "Container not running");
|
||||
const remoteNode = await findRemoteSwarmNode(appName, appType);
|
||||
ws.close(
|
||||
4000,
|
||||
remoteNode
|
||||
? `Container running on remote node "${remoteNode}"`.slice(0, 123)
|
||||
: "Container not running",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { stdout, stderr } = await execAsync(
|
||||
|
||||
@@ -60,6 +60,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
scheduleId: data.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: job.pattern || "",
|
||||
timezone: job.tz || data.timezone,
|
||||
});
|
||||
} else if (data.type === "volume-backup") {
|
||||
result = await removeJob({
|
||||
|
||||
@@ -66,9 +66,10 @@ export const removeJob = async (data: QueueJob) => {
|
||||
return result;
|
||||
}
|
||||
if (data.type === "schedule") {
|
||||
const { scheduleId, cronSchedule } = data;
|
||||
const { scheduleId, cronSchedule, timezone } = data;
|
||||
const result = await jobQueue.removeRepeatable(scheduleId, {
|
||||
pattern: cronSchedule,
|
||||
tz: timezone || "UTC",
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,28 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
|
||||
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
|
||||
const parseByteSize = (envVar: string, fallback: number): number => {
|
||||
const raw = process.env[envVar];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.warn(`Invalid ${envVar}="${raw}", using default ${fallback}`);
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const OPENAPI_MAX_JSON_BODY_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_JSON_BODY_SIZE",
|
||||
10 * 1024 * 1024, // 10mb
|
||||
);
|
||||
|
||||
export const OPENAPI_MAX_UPLOAD_SIZE = parseByteSize(
|
||||
"OPENAPI_MAX_UPLOAD_SIZE",
|
||||
1024 * 1024 * 1024, // 1gb
|
||||
);
|
||||
|
||||
type DockerSocketCandidate = {
|
||||
label: string;
|
||||
path: string;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
dockerId: text("dockerId"),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
|
||||
@@ -433,6 +433,7 @@ export const apiCreateGotify = notificationsSchema
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
serverUrl: z.string().min(1),
|
||||
@@ -468,6 +469,7 @@ export const apiCreateNtfy = notificationsSchema
|
||||
name: true,
|
||||
appDeploy: true,
|
||||
dockerCleanup: true,
|
||||
serverThreshold: true,
|
||||
})
|
||||
.extend({
|
||||
serverUrl: z.string().min(1),
|
||||
|
||||
@@ -11,6 +11,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
"phase",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
@@ -62,6 +63,15 @@ export const scalewayVaultConfigSchema = z.object({
|
||||
apiUrl: z.string().url().default("https://api.scaleway.com"),
|
||||
});
|
||||
|
||||
export const phaseVaultConfigSchema = z.object({
|
||||
providerType: z.literal("phase"),
|
||||
token: z.string().min(1),
|
||||
appId: z.string().min(1),
|
||||
env: z.string().min(1),
|
||||
path: z.string().default("/"),
|
||||
apiUrl: z.string().url().default("https://api.phase.dev"),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
@@ -69,6 +79,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
scalewayVaultConfigSchema,
|
||||
phaseVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const statements = {
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
|
||||
envVars: ["read", "write"],
|
||||
projectEnvVars: ["read", "write"],
|
||||
environmentEnvVars: ["read", "write"],
|
||||
server: ["read", "create", "delete"],
|
||||
server: ["read", "create", "delete", "terminal"],
|
||||
registry: ["read", "create", "delete"],
|
||||
certificate: ["read", "create", "update", "delete"],
|
||||
backup: ["read", "create", "update", "delete", "restore"],
|
||||
|
||||
@@ -3,7 +3,9 @@ import { promisify } from "node:util";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
@@ -154,7 +156,7 @@ const resolveDns = promisify(dns.resolve4);
|
||||
|
||||
export const validateDomain = async (
|
||||
domain: string,
|
||||
expectedIp?: string,
|
||||
expectedIps?: string[],
|
||||
): Promise<{
|
||||
isValid: boolean;
|
||||
resolvedIp?: string;
|
||||
@@ -186,13 +188,13 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
|
||||
// If we have an expected IP, validate against it
|
||||
if (expectedIp) {
|
||||
if (expectedIps && expectedIps.length > 0) {
|
||||
const isValid = resolvedIps.some((ip) => expectedIps.includes(ip));
|
||||
return {
|
||||
isValid: resolvedIps.includes(expectedIp),
|
||||
isValid,
|
||||
resolvedIp: resolvedIps.join(", "),
|
||||
error: !resolvedIps.includes(expectedIp)
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
|
||||
error: !isValid
|
||||
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIps.join(" or ")}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -210,3 +212,47 @@ export const validateDomain = async (
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerIpCandidates = async (
|
||||
serverId?: string | null,
|
||||
): Promise<string[]> => {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
if (server.ipAddress) {
|
||||
candidates.add(server.ipAddress);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(
|
||||
execAsyncRemote(
|
||||
serverId,
|
||||
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
|
||||
),
|
||||
7000,
|
||||
);
|
||||
const detectedIp = publicIp?.stdout?.trim();
|
||||
if (detectedIp) {
|
||||
candidates.add(detectedIp);
|
||||
}
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
if (settings?.serverIp) {
|
||||
candidates.add(settings.serverIp);
|
||||
}
|
||||
|
||||
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
|
||||
if (publicIp) {
|
||||
candidates.add(publicIp);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates);
|
||||
};
|
||||
|
||||
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
|
||||
]).catch(() => null);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -149,6 +152,7 @@ export const deployLibsql = async (
|
||||
}
|
||||
|
||||
await buildLibsql(libsql);
|
||||
await waitForSwarmServiceConvergence(libsql.appName, libsql.serverId);
|
||||
await updateLibsqlById(libsqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -154,6 +157,7 @@ export const deployMariadb = async (
|
||||
}
|
||||
|
||||
await buildMariadb(mariadb);
|
||||
await waitForSwarmServiceConvergence(mariadb.appName, mariadb.serverId);
|
||||
await updateMariadbById(mariadbId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -169,6 +172,7 @@ export const deployMongo = async (
|
||||
}
|
||||
|
||||
await buildMongo(mongo);
|
||||
await waitForSwarmServiceConvergence(mongo.appName, mongo.serverId);
|
||||
await updateMongoById(mongoId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -262,18 +262,28 @@ export const updateFileMount = async (mountId: string) => {
|
||||
if (!mount || !mount.filePath) return;
|
||||
const basePath = await getBaseFilesPath(mountId);
|
||||
const fullPath = path.join(basePath, mount.filePath);
|
||||
const directory = path.dirname(fullPath);
|
||||
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
const encodedContent = encodeBase64(mount.content || "");
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
|
||||
const command = `
|
||||
mkdir -p ${quote([directory])};
|
||||
if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi;
|
||||
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
|
||||
`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch {
|
||||
console.log("Error updating file mount");
|
||||
} catch (error) {
|
||||
console.log(`Error updating the file mount: ${error}`);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Error updating the mount ${error instanceof Error ? error.message : error}`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildMysql } from "@dokploy/server/utils/databases/mysql";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -152,6 +155,7 @@ export const deployMySql = async (
|
||||
}
|
||||
|
||||
await buildMysql(mysql);
|
||||
await waitForSwarmServiceConvergence(mysql.appName, mysql.serverId);
|
||||
await updateMySqlById(mysqlId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type Dockerode from "dockerode";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
@@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
|
||||
];
|
||||
|
||||
type DockerNetworkInfo = {
|
||||
Id?: string;
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Internal?: boolean;
|
||||
@@ -35,6 +37,12 @@ type DockerNetworkInfo = {
|
||||
};
|
||||
};
|
||||
|
||||
// EnableIPv4 is missing from dockerode's NetworkCreateOptions but supported
|
||||
// by the daemon (API >= 1.47); the body is sent as-is
|
||||
type NetworkCreateOptions = Dockerode.NetworkCreateOptions & {
|
||||
EnableIPv4?: boolean;
|
||||
};
|
||||
|
||||
const parseMtu = (value: string | undefined) => {
|
||||
const mtu = Number.parseInt(value ?? "", 10);
|
||||
return Number.isNaN(mtu) ? null : mtu;
|
||||
@@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
|
||||
serverId: string | null,
|
||||
) => ({
|
||||
name: dockerNetwork.Name,
|
||||
dockerId: dockerNetwork.Id ?? null,
|
||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
@@ -110,7 +119,7 @@ export const findNetworksToSync = async (
|
||||
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
|
||||
const dockerByName = new Map(dockerNetworks.map((d) => [d.Name, d] as const));
|
||||
|
||||
const importable = dockerNetworks
|
||||
.filter(
|
||||
@@ -128,12 +137,28 @@ export const findNetworksToSync = async (
|
||||
.filter((s): s is string => !!s),
|
||||
}));
|
||||
|
||||
// Rows in Dokploy whose network no longer exists in Docker
|
||||
const missing = existing
|
||||
.filter((row) => !dockerNames.has(row.name))
|
||||
.filter((row) => !dockerByName.has(row.name))
|
||||
.map((row) => ({ networkId: row.networkId, name: row.name }));
|
||||
|
||||
return { importable, missing };
|
||||
const changed = existing
|
||||
.filter((row) => {
|
||||
if (!row.dockerId) return false;
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return !!dockerNetwork?.Id && dockerNetwork.Id !== row.dockerId;
|
||||
})
|
||||
.map((row) => {
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return {
|
||||
networkId: row.networkId,
|
||||
name: row.name,
|
||||
driver: dockerNetwork?.Driver,
|
||||
internal: dockerNetwork?.Internal ?? false,
|
||||
attachable: dockerNetwork?.Attachable ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return { importable, missing, changed };
|
||||
};
|
||||
|
||||
export const importDockerNetworks = async (
|
||||
@@ -184,6 +209,49 @@ export const importDockerNetworks = async (
|
||||
return { imported, errors };
|
||||
};
|
||||
|
||||
export const resyncNetwork = async (
|
||||
networkId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
if (row.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
let info: DockerNetworkInfo;
|
||||
try {
|
||||
info = (await docker.getNetwork(row.name).inspect()) as DockerNetworkInfo;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(network)
|
||||
.set(mapDockerNetworkToRow(info, organizationId, row.serverId))
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
@@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
const createOptions: NetworkCreateOptions = {
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
CheckDuplicate: true,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
// EnableIPv4 is missing from dockerode's types but supported by
|
||||
// the daemon (API >= 1.47); the body is sent as-is
|
||||
EnableIPv4: row.enableIPv4,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
Options: row.mtu
|
||||
@@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
Driver: ipam.driver || "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
} as Parameters<typeof docker.createNetwork>[0]);
|
||||
};
|
||||
const created = await docker.createNetwork(createOptions);
|
||||
|
||||
await db
|
||||
.update(network)
|
||||
.set({ dockerId: created.id })
|
||||
.where(eq(network.networkId, row.networkId));
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
@@ -561,6 +561,7 @@ export const createGotifyNotification = async (
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
serverThreshold: input.serverThreshold,
|
||||
notificationType: "gotify",
|
||||
organizationId: organizationId,
|
||||
})
|
||||
@@ -593,6 +594,7 @@ export const updateGotifyNotification = async (
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
serverThreshold: input.serverThreshold,
|
||||
organizationId: input.organizationId,
|
||||
})
|
||||
.where(eq(notifications.notificationId, input.notificationId))
|
||||
@@ -655,6 +657,7 @@ export const createNtfyNotification = async (
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
serverThreshold: input.serverThreshold,
|
||||
notificationType: "ntfy",
|
||||
organizationId: organizationId,
|
||||
})
|
||||
@@ -687,6 +690,7 @@ export const updateNtfyNotification = async (
|
||||
volumeBackup: input.volumeBackup,
|
||||
dokployRestart: input.dokployRestart,
|
||||
dockerCleanup: input.dockerCleanup,
|
||||
serverThreshold: input.serverThreshold,
|
||||
organizationId: input.organizationId,
|
||||
})
|
||||
.where(eq(notifications.notificationId, input.notificationId))
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, getTableColumns } from "drizzle-orm";
|
||||
@@ -165,6 +168,8 @@ export const deployPostgres = async (
|
||||
|
||||
await buildPostgres(postgres);
|
||||
|
||||
await waitForSwarmServiceConvergence(postgres.appName, postgres.serverId);
|
||||
|
||||
await updatePostgresById(postgresId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { generatePassword } from "@dokploy/server/templates";
|
||||
import { buildRedis } from "@dokploy/server/utils/databases/redis";
|
||||
import { pullImage } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
pullImage,
|
||||
waitForSwarmServiceConvergence,
|
||||
} from "@dokploy/server/utils/docker/utils";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -119,6 +122,7 @@ export const deployRedis = async (
|
||||
}
|
||||
|
||||
await buildRedis(redis);
|
||||
await waitForSwarmServiceConvergence(redis.appName, redis.serverId);
|
||||
await updateRedisById(redisId, {
|
||||
applicationStatus: "done",
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
|
||||
),
|
||||
});
|
||||
|
||||
if (!composeResult) {
|
||||
if (composeResult.length === 0) {
|
||||
return;
|
||||
}
|
||||
let commands = "";
|
||||
|
||||
@@ -23,6 +23,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
scaleway: ["secretKey"],
|
||||
phase: ["token"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,7 +6,7 @@ const validateUfw = () => `
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
isInstalled=true
|
||||
isActive=$(sudo ufw status | grep -q "Status: active" && echo true || echo false)
|
||||
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}')
|
||||
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
echo "{\\"installed\\": $isInstalled, \\"active\\": $isActive, \\"defaultIncoming\\": \\"$defaultIncoming\\"}"
|
||||
else
|
||||
echo "{\\"installed\\": false, \\"active\\": false, \\"defaultIncoming\\": \\"unknown\\"}"
|
||||
@@ -27,7 +27,7 @@ const validateSsh = () => `
|
||||
|
||||
# Check for key authentication
|
||||
# SSH key auth is enabled by default unless explicitly disabled
|
||||
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#")
|
||||
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | head -n1)
|
||||
if [ -z "$pubkey_line" ] || echo "$pubkey_line" | grep -q -i "yes"; then
|
||||
keyAuth=true
|
||||
else
|
||||
@@ -36,21 +36,21 @@ const validateSsh = () => `
|
||||
|
||||
# Get the exact PermitRootLogin value from config
|
||||
# This preserves values like "prohibit-password" without normalization
|
||||
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$permitRootLogin" ]; then
|
||||
# Default is prohibit-password in newer versions
|
||||
permitRootLogin="prohibit-password"
|
||||
fi
|
||||
|
||||
# Get the exact PasswordAuthentication value from config
|
||||
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$passwordAuth" ]; then
|
||||
# Default is yes
|
||||
passwordAuth="yes"
|
||||
fi
|
||||
|
||||
# Get the exact UsePAM value from config
|
||||
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
|
||||
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
|
||||
if [ -z "$usePam" ]; then
|
||||
# Default is yes in most distros
|
||||
usePam="yes"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -21,11 +21,14 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(
|
||||
compose,
|
||||
mounts.length > 0 ? projectPath : undefined,
|
||||
);
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose)
|
||||
: "";
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const exportEnvCommand = getExportEnvCommand(compose);
|
||||
|
||||
const newCompose = await writeDomainsToCompose(compose, domains);
|
||||
@@ -119,7 +122,7 @@ const sanitizeCommand = (command: string) => {
|
||||
return restCommand.join(" ");
|
||||
};
|
||||
|
||||
export const createCommand = (compose: ComposeNested) => {
|
||||
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
const { composeType, appName, sourceType } = compose;
|
||||
if (compose.command) {
|
||||
return `${sanitizeCommand(compose.command)}`;
|
||||
@@ -130,7 +133,13 @@ export const createCommand = (compose: ComposeNested) => {
|
||||
let command = "";
|
||||
|
||||
if (composeType === "docker-compose") {
|
||||
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
|
||||
const projectDirectoryFlag = projectPath
|
||||
? `--project-directory ${quote([projectPath])} `
|
||||
: "";
|
||||
const envFileFlag = compose.createEnvFile
|
||||
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
|
||||
: "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans`;
|
||||
} else if (composeType === "stack") {
|
||||
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
# Ensure we have a builder with containerd (isolated per build)
|
||||
|
||||
export RAILPACK_VERSION=${application.railpackVersion}
|
||||
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||
# use sudo for non-root so the install can write to /usr/local/bin
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO_CMD=""
|
||||
elif sudo -n true 2>/dev/null; then
|
||||
SUDO_CMD="sudo"
|
||||
else
|
||||
SUDO_CMD=""
|
||||
fi
|
||||
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||
docker buildx create --name ${builderName} --driver docker-container || true
|
||||
|
||||
echo "Preparing Railpack build plan..." ;
|
||||
|
||||
@@ -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}"`;
|
||||
});
|
||||
};
|
||||
@@ -934,6 +934,57 @@ const getSwarmServiceContainerId = async (
|
||||
}
|
||||
};
|
||||
|
||||
export class ServiceConvergenceError extends Error {}
|
||||
|
||||
export const waitForSwarmServiceConvergence = async (
|
||||
appName: string,
|
||||
serverId?: string | null,
|
||||
options?: { timeoutMs?: number; intervalMs?: number },
|
||||
): Promise<void> => {
|
||||
const timeoutMs = options?.timeoutMs ?? 45_000;
|
||||
const intervalMs = options?.intervalMs ?? 2_000;
|
||||
const remoteDocker = await getRemoteDocker(serverId);
|
||||
const service = remoteDocker.getService(appName);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
let lastState = "unknown";
|
||||
while (true) {
|
||||
const info = await service.inspect();
|
||||
const desiredTasksCount = info.Spec?.Mode?.Replicated?.Replicas ?? 1;
|
||||
|
||||
const tasks = await remoteDocker.listTasks({
|
||||
filters: JSON.stringify({ service: [appName] }),
|
||||
});
|
||||
const currentTasks = tasks.filter(
|
||||
(task) => task.DesiredState === "running",
|
||||
);
|
||||
const runningTasksCount = currentTasks.filter(
|
||||
(task) => task.Status?.State === "running",
|
||||
).length;
|
||||
|
||||
if (runningTasksCount >= desiredTasksCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const failedTask = currentTasks.find((task) =>
|
||||
["failed", "rejected"].includes(task.Status?.State ?? ""),
|
||||
);
|
||||
lastState =
|
||||
failedTask?.Status?.Err ??
|
||||
failedTask?.Status?.State ??
|
||||
currentTasks[0]?.Status?.State ??
|
||||
lastState;
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
throw new ServiceConvergenceError(
|
||||
`Service ${appName} did not converge within ${timeoutMs}ms: ${runningTasksCount}/${desiredTasksCount} tasks running (last state: ${lastState})`,
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
|
||||
if (serviceCheck.status === "unhealthy") {
|
||||
|
||||
@@ -369,22 +369,28 @@ export const sendServerThresholdNotifications = async (
|
||||
`Server: ${payload.ServerName}\nType: ${payload.Type}\nCurrent: ${payload.Value.toFixed(2)}%\nThreshold: ${payload.Threshold.toFixed(2)}%\nMessage: ${payload.Message}\nTime: ${date.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (teams) {
|
||||
await sendTeamsNotification(teams, {
|
||||
title: `⚠️ Server ${payload.Type} Alert`,
|
||||
facts: [
|
||||
{ name: "Server Name", value: payload.ServerName },
|
||||
{ name: "Type", value: payload.Type },
|
||||
{
|
||||
name: "Current Value",
|
||||
value: `${payload.Value.toFixed(2)}%`,
|
||||
},
|
||||
{
|
||||
name: "Threshold",
|
||||
value: `${payload.Threshold.toFixed(2)}%`,
|
||||
},
|
||||
{ name: "Time", value: date.toLocaleString() },
|
||||
{ name: "Message", value: payload.Message },
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
if (teams) {
|
||||
await sendTeamsNotification(teams, {
|
||||
title: `⚠️ Server ${payload.Type} Alert`,
|
||||
facts: [
|
||||
{ name: "Server Name", value: payload.ServerName },
|
||||
{ name: "Type", value: payload.Type },
|
||||
{ name: "Current Value", value: `${payload.Value.toFixed(2)}%` },
|
||||
{ name: "Threshold", value: `${payload.Threshold.toFixed(2)}%` },
|
||||
{ name: "Time", value: date.toLocaleString() },
|
||||
{ name: "Message", value: payload.Message },
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,20 @@ import { findServerById } from "@dokploy/server/services/server";
|
||||
import { Client } from "ssh2";
|
||||
import { ExecError } from "./ExecError";
|
||||
|
||||
export class WriteFileRemoteError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly context: {
|
||||
remotePath: string;
|
||||
serverId: string;
|
||||
originalError: Error;
|
||||
},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WriteFileRemoteError";
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export ExecError for easier imports
|
||||
export { ExecError } from "./ExecError";
|
||||
|
||||
@@ -21,11 +35,11 @@ export const execAsync = async (
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// @ts-ignore - exec error has these properties
|
||||
// @ts-expect-error - exec error has these properties
|
||||
const exitCode = error.code;
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const stdout = error.stdout?.toString() || "";
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const stderr = error.stderr?.toString() || "";
|
||||
|
||||
throw new ExecError(`Command execution failed: ${error.message}`, {
|
||||
@@ -61,7 +75,7 @@ export const execAsyncStream = (
|
||||
command,
|
||||
stdout: stdoutComplete,
|
||||
stderr: stderrComplete,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
exitCode: error.code,
|
||||
originalError: error,
|
||||
}),
|
||||
@@ -249,6 +263,65 @@ export const execAsyncRemote = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const writeFileRemote = async (
|
||||
serverId: string,
|
||||
remotePath: string,
|
||||
content: string,
|
||||
): Promise<void> => {
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn
|
||||
.once("ready", () => {
|
||||
conn.sftp((err, sftp) => {
|
||||
if (err) {
|
||||
conn.end();
|
||||
reject(
|
||||
new WriteFileRemoteError(`SFTP session failed: ${err.message}`, {
|
||||
remotePath,
|
||||
serverId,
|
||||
originalError: err,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
sftp.writeFile(remotePath, content, (writeErr) => {
|
||||
conn.end();
|
||||
if (writeErr) {
|
||||
reject(
|
||||
new WriteFileRemoteError(
|
||||
`Failed to write remote file ${remotePath}: ${writeErr.message}`,
|
||||
{ remotePath, serverId, originalError: writeErr },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
.on("error", (err) => {
|
||||
conn.end();
|
||||
reject(
|
||||
new WriteFileRemoteError(`SSH connection error: ${err.message}`, {
|
||||
remotePath,
|
||||
serverId,
|
||||
originalError: err,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
privateKey: server.sshKey?.privateKey,
|
||||
timeout: 99999,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
@@ -5,8 +5,11 @@ import { paths } from "@dokploy/server/constants";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { encodeBase64 } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
writeFileRemote,
|
||||
} from "../process/execAsync";
|
||||
import type { FileConfig, HttpLoadBalancerService } from "./file-types";
|
||||
|
||||
export const createTraefikConfig = (appName: string) => {
|
||||
@@ -228,11 +231,7 @@ export const writeConfigRemote = async (
|
||||
try {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
await writeFileRemote(serverId, configPath, traefikConfig);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
}
|
||||
@@ -246,11 +245,7 @@ export const writeTraefikConfigInPath = async (
|
||||
try {
|
||||
const configPath = path.join(pathFile);
|
||||
if (serverId) {
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
await writeFileRemote(serverId, configPath, traefikConfig);
|
||||
} else {
|
||||
fs.writeFileSync(configPath, traefikConfig, "utf8");
|
||||
}
|
||||
@@ -282,16 +277,32 @@ export const writeTraefikConfigRemote = async (
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
const yamlStr = stringify(traefikConfig);
|
||||
const encoded = encodeBase64(yamlStr);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
await writeFileRemote(serverId, configPath, yamlStr);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyHttpRoutersAndServices = (traefikConfig: FileConfig) =>
|
||||
Object.keys(traefikConfig.http?.routers || {}).length === 0 &&
|
||||
Object.keys(traefikConfig.http?.services || {}).length === 0;
|
||||
|
||||
export const writeAppTraefikConfig = async (
|
||||
traefikConfig: FileConfig,
|
||||
appName: string,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
if (isEmptyHttpRoutersAndServices(traefikConfig)) {
|
||||
await removeTraefikConfig(appName, serverId);
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(traefikConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(traefikConfig, appName);
|
||||
}
|
||||
};
|
||||
|
||||
export const createServiceConfig = (
|
||||
appName: string,
|
||||
domain: Domain,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ApplicationNested } from "../builders";
|
||||
import {
|
||||
loadOrCreateConfig,
|
||||
loadOrCreateConfigRemote,
|
||||
writeTraefikConfig,
|
||||
writeAppTraefikConfig,
|
||||
writeTraefikConfigRemote,
|
||||
} from "./application";
|
||||
import type { FileConfig } from "./file-types";
|
||||
@@ -89,11 +89,10 @@ export const createRedirectMiddleware = async (
|
||||
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeMiddleware(config);
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
export const removeRedirectMiddleware = async (
|
||||
@@ -124,9 +123,8 @@ export const removeRedirectMiddleware = async (
|
||||
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
writeMiddleware(config);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ApplicationNested } from "../builders";
|
||||
import {
|
||||
loadOrCreateConfig,
|
||||
loadOrCreateConfigRemote,
|
||||
writeTraefikConfig,
|
||||
writeAppTraefikConfig,
|
||||
writeTraefikConfigRemote,
|
||||
} from "./application";
|
||||
import type {
|
||||
@@ -62,11 +62,10 @@ export const createSecurityMiddleware = async (
|
||||
addMiddleware(appConfig, middlewareName);
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(config, "middlewares", serverId);
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
writeMiddleware(config);
|
||||
}
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
};
|
||||
|
||||
export const removeSecurityMiddleware = async (
|
||||
@@ -89,6 +88,7 @@ export const removeSecurityMiddleware = async (
|
||||
appConfig = loadOrCreateConfig(appName);
|
||||
}
|
||||
const middlewareName = `auth-${appName}`;
|
||||
let removedLastUser = false;
|
||||
|
||||
if (config.http?.middlewares) {
|
||||
const currentMiddleware = config.http.middlewares[middlewareName];
|
||||
@@ -106,11 +106,7 @@ export const removeSecurityMiddleware = async (
|
||||
delete config.http.middlewares[middlewareName];
|
||||
}
|
||||
deleteMiddleware(appConfig, middlewareName);
|
||||
if (serverId) {
|
||||
await writeTraefikConfigRemote(appConfig, appName, serverId);
|
||||
} else {
|
||||
writeTraefikConfig(appConfig, appName);
|
||||
}
|
||||
removedLastUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +116,9 @@ export const removeSecurityMiddleware = async (
|
||||
} else {
|
||||
writeMiddleware(config);
|
||||
}
|
||||
if (removedLastUser) {
|
||||
await writeAppTraefikConfig(appConfig, appName, serverId);
|
||||
}
|
||||
};
|
||||
|
||||
const isBasicAuthMiddleware = (
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,106 @@
|
||||
import type { phaseVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type PhaseConfig = z.infer<typeof phaseVaultConfigSchema>;
|
||||
|
||||
type PhaseSecret = {
|
||||
key: string;
|
||||
value: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const baseUrl = (config: PhaseConfig) => config.apiUrl.replace(/\/+$/, "");
|
||||
|
||||
const authHeaders = (config: PhaseConfig) => ({
|
||||
Authorization: `Bearer ServiceAccount ${config.token}`,
|
||||
Accept: "application/json",
|
||||
});
|
||||
|
||||
const parseErrorDetail = async (response: Response) => {
|
||||
try {
|
||||
const body = (await response.json()) as {
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
return body.error ?? body.message ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const request = async (
|
||||
config: PhaseConfig,
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
) => {
|
||||
const url = new URL(`${baseUrl(config)}${path}`);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await vaultFetch(url.toString(), {
|
||||
headers: authHeaders(config),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const detail = await parseErrorDetail(response);
|
||||
const reason =
|
||||
response.status === 401 || response.status === 403
|
||||
? "authentication failed"
|
||||
: "request failed";
|
||||
throw new Error(
|
||||
`Phase: ${reason} (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: PhaseConfig) => {
|
||||
const response = await request(config, "/v1/secrets/", {
|
||||
app_id: config.appId,
|
||||
env: config.env,
|
||||
path: config.path || "/",
|
||||
});
|
||||
const body = (await response.json()) as PhaseSecret[];
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const secret of body ?? []) {
|
||||
secrets[secret.key] = secret.value;
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const phaseClient: VaultClient<PhaseConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Phase: secret "${ref}" not found in environment "${config.env}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const appResponse = await request(config, `/v1/apps/${config.appId}/`);
|
||||
const app = (await appResponse.json()) as { sseEnabled?: boolean };
|
||||
if (app.sseEnabled === false) {
|
||||
throw new Error(
|
||||
"Phase: enable Server-side Encryption (SSE) on this Phase App to use the REST secrets API",
|
||||
);
|
||||
}
|
||||
await fetchSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -8,5 +8,5 @@ export const shouldDeploy = (
|
||||
const files = (modifiedFiles ?? []).filter(
|
||||
(file): file is string => typeof file === "string",
|
||||
);
|
||||
return micromatch.some(files, watchPaths);
|
||||
return micromatch(files, watchPaths).length > 0;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+90
@@ -175,6 +175,9 @@ importers:
|
||||
'@octokit/webhooks':
|
||||
specifier: ^13.9.0
|
||||
version: 13.9.1
|
||||
'@openpanel/nextjs':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@react-email/components':
|
||||
specifier: ^1.0.12
|
||||
version: 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -2170,6 +2173,19 @@ packages:
|
||||
resolution: {integrity: sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@openpanel/nextjs@1.5.1':
|
||||
resolution: {integrity: sha512-60vhKw29weFWmwntx/cSIYU1TcwNzRykeg8P70onr3jzeyMGsEd78d/wgF5Mp/NIxQDN0IT4LneS6hIDIyrTpw==}
|
||||
peerDependencies:
|
||||
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@openpanel/sdk@1.3.1':
|
||||
resolution: {integrity: sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==}
|
||||
|
||||
'@openpanel/web@1.4.1':
|
||||
resolution: {integrity: sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==}
|
||||
|
||||
'@opentelemetry/api-logs@0.57.2':
|
||||
resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -3856,6 +3872,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rrweb/types@2.0.0-alpha.20':
|
||||
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
|
||||
|
||||
'@rrweb/utils@2.1.1':
|
||||
resolution: {integrity: sha512-x2SgJAD3YJ9eVcLZ5l6OqWMtczdA3VfS5XqPeqpZdQgV9oh6Id5GK2cDN4bimnkqryOcIJdz8cTvV0yNvqQ+nA==}
|
||||
|
||||
'@rvf/set-get@7.0.1':
|
||||
resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==}
|
||||
|
||||
@@ -4236,6 +4258,9 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/css-font-loading-module@0.0.7':
|
||||
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
@@ -4604,6 +4629,9 @@ packages:
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xstate/fsm@1.6.5':
|
||||
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
|
||||
|
||||
'@xterm/addon-attach@0.10.0':
|
||||
resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==}
|
||||
peerDependencies:
|
||||
@@ -4808,6 +4836,10 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -7076,6 +7108,9 @@ packages:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
@@ -8142,6 +8177,15 @@ packages:
|
||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
rrdom@2.1.1:
|
||||
resolution: {integrity: sha512-VBkTF3bGNqcZjqnbo/gKi9GVQPIxiG0dofw7CQdAZQFQ2juJdt2YKIf3oS9QudL8HTpGh9bz5TUN+3amBn1Geg==}
|
||||
|
||||
rrweb-snapshot@2.1.1:
|
||||
resolution: {integrity: sha512-al4Kx7Am7YlIn/5Yb2EMr7cdmjGiK+cLhdilJEXqkFXgpnys8t/yMJumXy2Ormgirpg3MBnFha0GTgny+iIL2w==}
|
||||
|
||||
rrweb@2.0.0-alpha.20:
|
||||
resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==}
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -10697,6 +10741,21 @@ snapshots:
|
||||
'@octokit/request-error': 6.1.8
|
||||
'@octokit/webhooks-methods': 5.1.1
|
||||
|
||||
'@openpanel/nextjs@1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@openpanel/web': 1.4.1
|
||||
next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@openpanel/sdk@1.3.1': {}
|
||||
|
||||
'@openpanel/web@1.4.1':
|
||||
dependencies:
|
||||
'@openpanel/sdk': 1.3.1
|
||||
'@rrweb/types': 2.0.0-alpha.20
|
||||
rrweb: 2.0.0-alpha.20
|
||||
|
||||
'@opentelemetry/api-logs@0.57.2':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -12688,6 +12747,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||
optional: true
|
||||
|
||||
'@rrweb/types@2.0.0-alpha.20': {}
|
||||
|
||||
'@rrweb/utils@2.1.1': {}
|
||||
|
||||
'@rvf/set-get@7.0.1': {}
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
@@ -13367,6 +13430,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@types/css-font-loading-module@0.0.7': {}
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
@@ -13711,6 +13776,8 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
'@xstate/fsm@1.6.5': {}
|
||||
|
||||
'@xterm/addon-attach@0.10.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
@@ -13895,6 +13962,8 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.0: {}
|
||||
@@ -16154,6 +16223,8 @@ snapshots:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
@@ -17331,6 +17402,25 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
rrdom@2.1.1:
|
||||
dependencies:
|
||||
rrweb-snapshot: 2.1.1
|
||||
|
||||
rrweb-snapshot@2.1.1:
|
||||
dependencies:
|
||||
postcss: 8.5.23
|
||||
|
||||
rrweb@2.0.0-alpha.20:
|
||||
dependencies:
|
||||
'@rrweb/types': 2.0.0-alpha.20
|
||||
'@rrweb/utils': 2.1.1
|
||||
'@types/css-font-loading-module': 0.0.7
|
||||
'@xstate/fsm': 1.6.5
|
||||
base64-arraybuffer: 1.0.2
|
||||
mitt: 3.0.1
|
||||
rrdom: 2.1.1
|
||||
rrweb-snapshot: 2.1.1
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
|
||||
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