mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2d4d5c19 | |||
| fa3cab8bcc | |||
| 8f42dced38 | |||
| 7a0efc7aa4 | |||
| 65c5ccc7b2 | |||
| 5d1731c54f |
+1
-4
@@ -31,7 +31,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@@ -69,8 +69,5 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
// Real end-to-end: the same Environment Settings text must reach a *running*
|
||||
// container with identical values under docker-compose and under swarm stack.
|
||||
// Covers https://github.com/Dokploy/dokploy/issues/5095 and /5096.
|
||||
|
||||
// exactly what a user types in the Environment Settings tab
|
||||
const uiEnv = [
|
||||
"SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
|
||||
"PASSWORD=mysecret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"SHEBANG=#!secret",
|
||||
"DOLLARS=pa$$word",
|
||||
"SPACED=hello world",
|
||||
'JSON={"a":1}',
|
||||
"COMMENTED=value # a real comment",
|
||||
'QUOTED_HASH="abc#de"',
|
||||
].join("\n");
|
||||
|
||||
const expected: Record<string, string> = {
|
||||
SENTRY_DSN: "https://examplePublicKey@o0.ingest.sentry.io/0",
|
||||
PASSWORD: "mysecret#",
|
||||
MIDDLE: "sec#ret",
|
||||
SHEBANG: "#!secret",
|
||||
DOLLARS: "pa$$word",
|
||||
SPACED: "hello world",
|
||||
JSON: '{"a":1}',
|
||||
COMMENTED: "value",
|
||||
QUOTED_HASH: "abc#de",
|
||||
};
|
||||
|
||||
const keys = Object.keys(expected);
|
||||
const composeApp = `env-delivery-c${process.pid}`;
|
||||
const stackApp = `env-delivery-s${process.pid}`;
|
||||
const pathFor = (app: string) =>
|
||||
join(process.cwd(), ".docker", "compose", app, "code");
|
||||
|
||||
const sh = (cmd: string, args: string[], cwd?: string) =>
|
||||
execFileSync(cmd, args, { cwd, encoding: "utf8" });
|
||||
|
||||
const setup = (
|
||||
app: string,
|
||||
composeType: "docker-compose" | "stack",
|
||||
svc: string,
|
||||
) => {
|
||||
const codePath = pathFor(app);
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
sh("bash", [
|
||||
"-c",
|
||||
getCreateEnvFileCommand({
|
||||
appName: app,
|
||||
composePath: "docker-compose.yml",
|
||||
composeType,
|
||||
env: uiEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]),
|
||||
]);
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), svc);
|
||||
return codePath;
|
||||
};
|
||||
|
||||
// `printenv` per key, NUL-delimited so values keep their spaces and #
|
||||
const dumpScript = keys.map((k) => `printf '%s\\0' "$${k}"`).join("; ");
|
||||
|
||||
const parseDump = (out: string) =>
|
||||
Object.fromEntries(
|
||||
out
|
||||
.split("\0")
|
||||
.slice(0, keys.length)
|
||||
.map((v, i) => [keys[i] as string, v]),
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
sh("docker", ["stack", "rm", stackApp]);
|
||||
} catch {}
|
||||
try {
|
||||
sh("docker", ["compose", "down", "--remove-orphans"], pathFor(composeApp));
|
||||
} catch {}
|
||||
for (const app of [composeApp, stackApp]) {
|
||||
rmSync(join(process.cwd(), ".docker", "compose", app), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("env delivery into a running container", () => {
|
||||
it("docker-compose: values survive compose-go interpolation", () => {
|
||||
const codePath = setup(
|
||||
composeApp,
|
||||
"docker-compose",
|
||||
"services:\n app:\n image: busybox\n env_file: .env\n",
|
||||
);
|
||||
|
||||
const out = sh(
|
||||
"docker",
|
||||
["compose", "run", "--rm", "-T", "app", "sh", "-c", dumpScript],
|
||||
codePath,
|
||||
);
|
||||
|
||||
expect(parseDump(out)).toEqual(expected);
|
||||
}, 120000);
|
||||
|
||||
it("swarm stack: values survive the literal env-file parser", () => {
|
||||
const codePath = setup(
|
||||
stackApp,
|
||||
"stack",
|
||||
"services:\n app:\n image: busybox\n command: sleep 300\n env_file: .env\n",
|
||||
);
|
||||
|
||||
sh(
|
||||
"docker",
|
||||
["stack", "deploy", "-c", "docker-compose.yml", stackApp],
|
||||
codePath,
|
||||
);
|
||||
|
||||
let containerId = "";
|
||||
for (let i = 0; i < 60 && !containerId; i++) {
|
||||
containerId = (
|
||||
sh("docker", [
|
||||
"ps",
|
||||
"--filter",
|
||||
`label=com.docker.swarm.service.name=${stackApp}_app`,
|
||||
"--format",
|
||||
"{{.ID}}",
|
||||
]).split("\n")[0] ?? ""
|
||||
).trim();
|
||||
if (!containerId) execFileSync("sleep", ["1"]);
|
||||
}
|
||||
expect(containerId, "swarm task never started").toBeTruthy();
|
||||
|
||||
const out = sh("docker", ["exec", containerId, "sh", "-c", dumpScript]);
|
||||
|
||||
expect(parseDump(out)).toEqual(expected);
|
||||
}, 180000);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { prepareEnvironmentVariablesForFile } from "@dokploy/server/utils/docker/utils";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// https://github.com/Dokploy/dokploy/issues/5096 — compose-go interpolates the
|
||||
// generated .env, docker stack deploy reads it literally; quoting cannot serve both.
|
||||
|
||||
const appName = `env-file-compose-type-${process.pid}`;
|
||||
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||
const codePath = join(projectPath, "code");
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(projectPath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
const buildEnvFile = (composeType: "docker-compose" | "stack", env: string) => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
composeType,
|
||||
env,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
execFileSync("bash", ["-c", command]);
|
||||
return readFileSync(join(codePath, ".env"), "utf8");
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile", () => {
|
||||
const env = "DSN=https://key@o0.io/0\nPASSWORD=pa$$word";
|
||||
|
||||
it("quotes and escapes by default, for the docker-compose .env", () => {
|
||||
expect(prepareEnvironmentVariablesForFile(env, "", "")).toEqual([
|
||||
'DSN="https://key@o0.io/0"',
|
||||
'PASSWORD="pa\\$\\$word"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes values untouched when raw", () => {
|
||||
expect(prepareEnvironmentVariablesForFile(env, "", "", true)).toEqual([
|
||||
"DSN=https://key@o0.io/0",
|
||||
"PASSWORD=pa$$word",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
it("writes a stack .env that docker reads back without the wrapping quotes", () => {
|
||||
const contents = buildEnvFile("stack", "DSN=https://key@o0.io/0");
|
||||
expect(contents).toContain("DSN=https://key@o0.io/0");
|
||||
expect(contents).not.toContain('DSN="');
|
||||
|
||||
writeFileSync(
|
||||
join(codePath, "docker-compose.yml"),
|
||||
"services:\n test:\n image: busybox\n env_file: .env\n",
|
||||
);
|
||||
|
||||
const rendered = execFileSync(
|
||||
"docker",
|
||||
["stack", "config", "-c", "docker-compose.yml"],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(rendered).toContain("DSN: https://key@o0.io/0");
|
||||
// #5096 symptom: quotes surviving into the value
|
||||
expect(rendered).not.toContain("DSN: '\"");
|
||||
}, 60000);
|
||||
|
||||
it("keeps quoting the docker-compose .env so $ survives interpolation", () => {
|
||||
const contents = buildEnvFile("docker-compose", "PASSWORD=pa$$word");
|
||||
expect(contents).toContain('PASSWORD="pa\\$\\$word"');
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,13 @@ const cases: Record<string, string> = {
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the env input parser resolves it
|
||||
// to the raw string in `cases` above.
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
// parser resolves it to the raw string in `cases` above.
|
||||
const inputEncoding: Record<string, string> = {
|
||||
PASSWORD: "pa$$word",
|
||||
SPECIAL: `'!"#$%&/()=?'`,
|
||||
NESTED_JSON: '{"nested":{"a":1}}',
|
||||
MAIL_PASSWORD: "abc#de",
|
||||
MAIL_PASSWORD: `"abc#de"`,
|
||||
TRAILING_BACKSLASH: "trailing\\",
|
||||
QUOTE_INSIDE: 'she said "hi"',
|
||||
APOSTROPHE: "it's a test",
|
||||
|
||||
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
|
||||
queueAdd: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
shouldDeploy: vi.fn(),
|
||||
createPreviewDeployment: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
@@ -64,10 +66,11 @@ vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
shouldDeploy: mocks.shouldDeploy,
|
||||
checkUserRepositoryPermissions: vi.fn(),
|
||||
createPreviewDeployment: vi.fn(),
|
||||
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||
createSecurityBlockedComment: vi.fn(),
|
||||
findGithubById: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId:
|
||||
mocks.findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
getBitbucketHeaders: vi.fn(() => ({})),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
@@ -321,3 +324,157 @@ describe("GitHub app webhook auto-deploy", () => {
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub app webhook preview deployments", () => {
|
||||
const createApplication = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
applicationId: "application-id",
|
||||
name: "my-app",
|
||||
serverId: null,
|
||||
previewLabels: [],
|
||||
previewLimit: 3,
|
||||
previewDeployments: [],
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPreviewDeployments = (total: number) =>
|
||||
Array.from({ length: total }, (_, index) => ({
|
||||
previewDeploymentId: `existing-preview-${index}`,
|
||||
}));
|
||||
|
||||
const createPullRequestRequest = (action: string) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "pull_request",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
action,
|
||||
pull_request: {
|
||||
id: 987,
|
||||
number: 42,
|
||||
title: "feat: add preview",
|
||||
html_url: "https://github.com/agentHits/dokploy/pull/42",
|
||||
labels: [],
|
||||
user: {
|
||||
login: "agentHits",
|
||||
},
|
||||
head: {
|
||||
ref: "feature",
|
||||
sha: "abc123",
|
||||
},
|
||||
base: {
|
||||
ref: "main",
|
||||
},
|
||||
},
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
owner: {
|
||||
login: "agentHits",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
mocks.createPreviewDeployment.mockResolvedValue({
|
||||
previewDeploymentId: "new-preview-id",
|
||||
});
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("redeploys an existing preview even when the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(3),
|
||||
}),
|
||||
]);
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("synchronize"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("does not create a new preview once the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("falls back to the default limit when none is configured", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: null,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
branch: "feature",
|
||||
pullRequestId: 987,
|
||||
pullRequestNumber: 42,
|
||||
}),
|
||||
);
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "new-preview-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// https://github.com/Dokploy/dokploy/issues/5095 — expectations calibrated against docker compose v2
|
||||
|
||||
describe("prepareEnvironmentVariables (# in values)", () => {
|
||||
it("preserves # when it is not an inline comment", () => {
|
||||
const serviceEnv = [
|
||||
"TRAILING=secret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"MULTI=a#b#c",
|
||||
"LEADING=#!secret",
|
||||
"NO_SPACE=value# not a comment",
|
||||
"CONN=user=admin;password=b#c",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"TRAILING=secret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"MULTI=a#b#c",
|
||||
"LEADING=#!secret",
|
||||
"NO_SPACE=value# not a comment",
|
||||
"CONN=user=admin;password=b#c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips an inline comment only after one or more spaces", () => {
|
||||
const serviceEnv = [
|
||||
"ONE_SPACE=value # comment",
|
||||
"TWO_SPACES=value # comment",
|
||||
"TRAILING_WS=value ",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"ONE_SPACE=value",
|
||||
"TWO_SPACES=value",
|
||||
"TRAILING_WS=value",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat a tab-preceded # as a comment", () => {
|
||||
const serviceEnv = "WITH_TAB=value\t# not a comment";
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"WITH_TAB=value\t# not a comment",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a bare `KEY= # ...` as an empty value", () => {
|
||||
// deliberate divergence: compose reads "# this is a comment" here
|
||||
const serviceEnv = "ONLY_HASH= # this is a comment";
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"ONLY_HASH=",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves # inside quoted values and still strips a trailing comment", () => {
|
||||
const serviceEnv = [
|
||||
'DOUBLE="val#ue"',
|
||||
"SINGLE='val#ue'",
|
||||
'DOUBLE_THEN_COMMENT="a # b" # comment',
|
||||
"SINGLE_THEN_COMMENT='c # d' # comment",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"DOUBLE=val#ue",
|
||||
"SINGLE=val#ue",
|
||||
"DOUBLE_THEN_COMMENT=a # b",
|
||||
"SINGLE_THEN_COMMENT=c # d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still skips full-line comments", () => {
|
||||
const serviceEnv = [
|
||||
"# leading comment",
|
||||
"FIRST=one",
|
||||
" # indented comment",
|
||||
"SECOND=two",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"FIRST=one",
|
||||
"SECOND=two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves # through project, environment and self references", () => {
|
||||
const projectEnv = "DB_PASS=secret#end";
|
||||
const environmentEnv = "API_KEY=key#123";
|
||||
const serviceEnv = [
|
||||
"DATABASE_URL=postgres://user:${{project.DB_PASS}}@db:5432/app",
|
||||
"TOKEN=${{environment.API_KEY}}",
|
||||
"LOCAL_SECRET=self#value",
|
||||
"COPY=${{LOCAL_SECRET}}",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
prepareEnvironmentVariables(serviceEnv, projectEnv, environmentEnv),
|
||||
).toEqual([
|
||||
"DATABASE_URL=postgres://user:secret#end@db:5432/app",
|
||||
"TOKEN=key#123",
|
||||
"LOCAL_SECRET=self#value",
|
||||
"COPY=self#value",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariables (dotenv parity)", () => {
|
||||
it("keeps the behaviour dotenv had for everything except #", () => {
|
||||
const serviceEnv = [
|
||||
"export EXPORTED=value",
|
||||
'ESCAPED="line1\\nline2"',
|
||||
"EMPTY=",
|
||||
'EMPTY_DOUBLE=""',
|
||||
"EMPTY_SINGLE=''",
|
||||
"DOLLARS=pa$$word",
|
||||
'SPACED_QUOTED= "padded" ',
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"EXPORTED=value",
|
||||
"ESCAPED=line1\nline2",
|
||||
"EMPTY=",
|
||||
"EMPTY_DOUBLE=",
|
||||
"EMPTY_SINGLE=",
|
||||
"DOLLARS=pa$$word",
|
||||
"SPACED_QUOTED=padded",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports multiline double-quoted values", () => {
|
||||
const serviceEnv = 'MULTILINE="line1\nline2"';
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"MULTILINE=line1\nline2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -53,9 +53,14 @@ type Tag = z.infer<typeof TagSchema>;
|
||||
|
||||
interface HandleTagProps {
|
||||
tagId?: string;
|
||||
/**
|
||||
* Called with the new tag's id after it is created, so callers embedding
|
||||
* this dialog (e.g. the tag selector) can select the tag right away.
|
||||
*/
|
||||
onCreated?: (tagId: string) => void;
|
||||
}
|
||||
|
||||
export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
export const HandleTag = ({ tagId, onCreated }: HandleTagProps) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -101,8 +106,11 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
color: data.color,
|
||||
tagId: tagId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
.then(async (result) => {
|
||||
await utils.tag.all.invalidate();
|
||||
if (!tagId && result?.tagId) {
|
||||
onCreated?.(result.tagId);
|
||||
}
|
||||
toast.success(tagId ? "Tag Updated" : "Tag Created");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
@@ -139,9 +147,18 @@ export const HandleTag = ({ tagId }: HandleTagProps) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
{/*
|
||||
* This dialog can be rendered inside another form (e.g. the tag
|
||||
* selector in the project dialog). React propagates events through the
|
||||
* React tree, not the DOM tree, so without stopPropagation submitting
|
||||
* this form would also submit the outer one.
|
||||
*/}
|
||||
<form
|
||||
id="hook-form-tag"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={(e) => {
|
||||
e.stopPropagation();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
|
||||
@@ -147,7 +147,13 @@ export function TagSelector({
|
||||
})}
|
||||
</CommandGroup>
|
||||
<div className="flex items-center justify-center p-2 border-t">
|
||||
<HandleTag />
|
||||
<HandleTag
|
||||
onCreated={(tagId) => {
|
||||
if (!selectedTags.includes(tagId)) {
|
||||
onTagsChange([...selectedTags, tagId]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -22,7 +22,7 @@ function Switch({
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[18px] group-data-[size=sm]/switch:data-checked:translate-x-[10px] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.0",
|
||||
"version": "v0.30.2",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -484,10 +484,6 @@ export default async function handler(
|
||||
if (!hasLabel) continue;
|
||||
}
|
||||
|
||||
const previewLimit = app?.previewLimit || 0;
|
||||
if (app?.previewDeployments?.length > previewLimit) {
|
||||
continue;
|
||||
}
|
||||
const previewDeploymentResult =
|
||||
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
|
||||
|
||||
@@ -495,6 +491,15 @@ export default async function handler(
|
||||
previewDeploymentResult?.previewDeploymentId || "";
|
||||
|
||||
if (!previewDeploymentResult && shouldCreateDeployment) {
|
||||
// The limit only applies to new previews, existing ones must
|
||||
// still be redeployed when the pull request is updated.
|
||||
const previewLimit = app?.previewLimit ?? 3;
|
||||
if ((app?.previewDeployments?.length ?? 0) >= previewLimit) {
|
||||
console.warn(
|
||||
`⚠️ Preview deployment limit (${previewLimit}) reached for ${app.name}, skipping preview for pull request #${prNumber}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: app.applicationId as string,
|
||||
branch: prBranch,
|
||||
|
||||
@@ -177,17 +177,6 @@ else
|
||||
OS_VERSION=$(grep -w "VERSION_ID" /etc/os-release | cut -d "=" -f 2 | tr -d '"')
|
||||
fi
|
||||
|
||||
# Ubuntu 26.04 (resolute) ships a docker-ce repo that has no 28.5.0, so the
|
||||
# default pin is absent from apt-cache madison and get.docker.com aborts before
|
||||
# installing Docker. Repin to a version present on every architecture Docker
|
||||
# ships resolute for: amd64/arm64/armhf start at 29.3.1, but s390x only carries
|
||||
# 29.4.0-29.4.2, so 29.4.2 is the newest version common to all of them. This
|
||||
# must run after OS_VERSION is resolved and before the banner so the reported
|
||||
# Docker version stays accurate.
|
||||
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "26.04" ]; then
|
||||
DOCKER_VERSION=29.4.2
|
||||
fi
|
||||
|
||||
if [ "$OS_TYPE" = 'amzn' ]; then
|
||||
$SUDO_CMD dnf install -y findutils >/dev/null
|
||||
fi
|
||||
|
||||
@@ -7,6 +7,7 @@ import { writeDomainsToCompose } from "../docker/domain";
|
||||
import {
|
||||
encodeBase64,
|
||||
getEnvironmentVariablesObject,
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
} from "../docker/utils";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
@@ -157,12 +158,18 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
||||
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
||||
}
|
||||
|
||||
const envFileContent = prepareEnvironmentVariablesForFile(
|
||||
envContent,
|
||||
compose.environment.project.env,
|
||||
compose.environment.env,
|
||||
// docker stack deploy reads env_file literally, no unquoting
|
||||
compose.composeType === "stack",
|
||||
const envFileContent = (
|
||||
compose.composeType === "stack"
|
||||
? prepareEnvironmentVariables(
|
||||
envContent,
|
||||
compose.environment.project.env,
|
||||
compose.environment.env,
|
||||
)
|
||||
: prepareEnvironmentVariablesForFile(
|
||||
envContent,
|
||||
compose.environment.project.env,
|
||||
compose.environment.env,
|
||||
)
|
||||
).join("\n");
|
||||
|
||||
const encodedContent = encodeBase64(envFileContent);
|
||||
|
||||
@@ -456,16 +456,6 @@ export const removeService = async (
|
||||
}
|
||||
};
|
||||
|
||||
// dotenv ends an unquoted value at the first `#`; compose only does so after a space
|
||||
const HASH_PLACEHOLDER = "\u0000dokploy-hash\u0000";
|
||||
|
||||
const parseEnvVariables = (src: string): Record<string, string> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(parse(src.replace(/(?<! )#/g, HASH_PLACEHOLDER))).map(
|
||||
([key, value]) => [key, value.replaceAll(HASH_PLACEHOLDER, "#")],
|
||||
),
|
||||
);
|
||||
|
||||
export const prepareEnvironmentVariables = (
|
||||
serviceEnv: string | null,
|
||||
projectEnv?: string | null,
|
||||
@@ -478,9 +468,9 @@ export const prepareEnvironmentVariables = (
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectVars = parseEnvVariables(projectEnv ?? "");
|
||||
const environmentVars = parseEnvVariables(environmentEnv ?? "");
|
||||
const serviceVars = parseEnvVariables(serviceEnv ?? "");
|
||||
const projectVars = parse(projectEnv ?? "");
|
||||
const environmentVars = parse(environmentEnv ?? "");
|
||||
const serviceVars = parse(serviceEnv ?? "");
|
||||
|
||||
const resolvedVars = Object.entries(serviceVars).map(([key, value]) => {
|
||||
let resolvedValue = value;
|
||||
@@ -546,7 +536,6 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
serviceEnv: string | null,
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
rawValues = false,
|
||||
): string[] => {
|
||||
const envVars = prepareEnvironmentVariables(
|
||||
serviceEnv,
|
||||
@@ -554,8 +543,6 @@ export const prepareEnvironmentVariablesForFile = (
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
if (rawValues) return envVars;
|
||||
|
||||
return envVars.map((pair) => {
|
||||
const [key, value] = parseEnvironmentKeyValuePair(pair);
|
||||
const escapedValue = value
|
||||
|
||||
Reference in New Issue
Block a user