Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions apps/dokploy/__test__/env/application-env-upsert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { upsertApplicationEnvironment } from "@dokploy/server/services/application";
import { getApplicationEnvRevision } from "@dokploy/server/utils/env-upsert";
import { beforeEach, describe, expect, it, vi } from "vitest";

const dbMocks = vi.hoisted(() => {
const returning = vi.fn();
const where = vi.fn(() => ({ returning }));
const set = vi.fn(() => ({ where }));
const update = vi.fn(() => ({ set }));
const findFirst = vi.fn();

return {
findFirst,
returning,
set,
update,
where,
};
});

vi.mock("@dokploy/server/db", () => ({
db: {
query: {
applications: {
findFirst: dbMocks.findFirst,
},
},
update: dbMocks.update,
},
}));

const mockApplication = (env: string | null = null) => ({
applicationId: "app_1",
env,
});

describe("upsertApplicationEnvironment", () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.returning.mockResolvedValue([mockApplication()]);
});

it("returns dry run metadata without saving raw values", async () => {
dbMocks.findFirst.mockResolvedValue(
mockApplication("API_URL=https://old.example.com\nREDIS_PASSWORD=old"),
);

const result = await upsertApplicationEnvironment({
applicationId: "app_1",
variables: {
API_URL: "https://api.example.com",
REDIS_PASSWORD: "new",
},
dryRun: true,
});

expect(dbMocks.update).not.toHaveBeenCalled();
expect(result).toEqual({
applicationId: "app_1",
changed: true,
revision: getApplicationEnvRevision(
"app_1",
"API_URL=https://old.example.com\nREDIS_PASSWORD=old",
),
dryRun: true,
variables: [
{
name: "API_URL",
action: "updated",
secret: false,
},
{
name: "REDIS_PASSWORD",
action: "updated",
secret: true,
},
],
});
expect(JSON.stringify(result)).not.toContain("new");
});

it("saves only the merged environment when the revision matches", async () => {
const currentEnv = "API_URL=https://old.example.com\nREDIS_PASSWORD=old";
dbMocks.findFirst.mockResolvedValue(mockApplication(currentEnv));

const result = await upsertApplicationEnvironment({
applicationId: "app_1",
variables: {
API_URL: "https://api.example.com",
REDIS_HOST: "redis-dev",
},
expectedRevision: getApplicationEnvRevision("app_1", currentEnv),
});

expect(dbMocks.set).toHaveBeenCalledWith({
env: "API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev",
});
expect(result.changed).toBe(true);
expect(result.revision).toBe(
getApplicationEnvRevision(
"app_1",
"API_URL=https://api.example.com\nREDIS_PASSWORD=old\nREDIS_HOST=redis-dev",
),
);
});

it("rejects stale expected revisions without writing", async () => {
dbMocks.findFirst.mockResolvedValue(mockApplication("API_URL=https://old"));

await expect(
upsertApplicationEnvironment({
applicationId: "app_1",
variables: {
API_URL: "https://api.example.com",
},
expectedRevision: "env:stale",
}),
).rejects.toMatchObject({
code: "CONFLICT",
message: "Application environment revision does not match",
});
expect(dbMocks.update).not.toHaveBeenCalled();
});
});
86 changes: 86 additions & 0 deletions apps/dokploy/__test__/env/upsert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
getApplicationEnvRevision,
isSecretEnvName,
upsertEnvVariables,
} from "@dokploy/server/utils/env-upsert";
import { describe, expect, it } from "vitest";

describe("upsertEnvVariables", () => {
it("updates existing variables and appends new variables without removing existing secrets", () => {
const result = upsertEnvVariables(
`# Existing app env
API_URL=https://old.example.com
REDIS_PASSWORD=secret-value
`,
{
API_URL: "https://api.example.com",
REDIS_HOST: "redis-dev",
REDIS_PASSWORD: "secret-value",
},
);

expect(result.changed).toBe(true);
expect(result.env).toBe(`# Existing app env
API_URL=https://api.example.com
REDIS_PASSWORD=secret-value
REDIS_HOST=redis-dev
`);
expect(result.variables).toEqual([
{
name: "API_URL",
action: "updated",
secret: false,
},
{
name: "REDIS_HOST",
action: "created",
secret: false,
},
{
name: "REDIS_PASSWORD",
action: "unchanged",
secret: true,
},
]);
});

it("quotes values that would be unsafe as plain dotenv values", () => {
const result = upsertEnvVariables("", {
PLAIN_VALUE: "redis-dev",
SPACED_VALUE: "redis dev",
MULTILINE_VALUE: "first\nsecond",
HASH_VALUE: "value # not a comment",
});

expect(result.env).toBe(`PLAIN_VALUE=redis-dev
SPACED_VALUE="redis dev"
MULTILINE_VALUE="first\\nsecond"
HASH_VALUE="value # not a comment"`);
});

it("marks common credential names as secret metadata", () => {
expect(isSecretEnvName("REDIS_PASSWORD")).toBe(true);
expect(isSecretEnvName("API_TOKEN")).toBe(true);
expect(isSecretEnvName("PUBLIC_URL")).toBe(false);
});

it("creates a stable opaque revision that changes when the env changes", () => {
const firstRevision = getApplicationEnvRevision(
"app_1",
"REDIS_PASSWORD=secret-value",
);
const sameRevision = getApplicationEnvRevision(
"app_1",
"REDIS_PASSWORD=secret-value",
);
const nextRevision = getApplicationEnvRevision(
"app_1",
"REDIS_PASSWORD=new-secret-value",
);

expect(firstRevision).toBe(sameRevision);
expect(firstRevision).not.toBe(nextRevision);
expect(firstRevision).toMatch(/^env:/);
expect(firstRevision).not.toContain("secret-value");
});
});
16 changes: 16 additions & 0 deletions apps/dokploy/pages/api/[...trpc].ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,24 @@ import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { handleApplicationEnvUpsert } from "./application.env.upsert";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const slashPath = Array.isArray(req.query.trpc)
? req.query.trpc.join("/")
: req.query.trpc;
const dotPath = Array.isArray(req.query.trpc)
? req.query.trpc.join(".")
: req.query.trpc;

if (
slashPath === "application/env/upsert" ||
dotPath === "application.env.upsert"
) {
await handleApplicationEnvUpsert(req, res);
return;
}

const { session, user } = await validateRequest(req);

if (!user || !session) {
Expand Down
154 changes: 154 additions & 0 deletions apps/dokploy/pages/api/application.env.upsert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
findApplicationById,
IS_CLOUD,
upsertApplicationEnvironment,
validateRequest,
} from "@dokploy/server";
import { apiUpsertApplicationEnv } from "@dokploy/server/db/schema";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import type { NextApiRequest, NextApiResponse } from "next";
import { ZodError } from "zod";
import { audit } from "@/server/api/utils/audit";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";

const getErrorStatus = (error: unknown) => {
if (error instanceof ZodError) {
return 400;
}

if (!(error instanceof TRPCError)) {
return 500;
}

switch (error.code) {
case "BAD_REQUEST":
return 400;
case "UNAUTHORIZED":
return 403;
case "NOT_FOUND":
return 404;
case "CONFLICT":
return 409;
default:
return 500;
}
};

const getErrorMessage = (error: unknown) => {
if (error instanceof ZodError) {
return "Invalid request body";
}

if (error instanceof Error) {
return error.message;
}

return "Internal server error";
};

export const handleApplicationEnvUpsert = async (
req: NextApiRequest,
res: NextApiResponse,
) => {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.status(405).json({ message: "Method Not Allowed" });
return;
}

try {
const { session, user } = await validateRequest(req);

if (!user || !session) {
res.status(401).json({ message: "Unauthorized" });
return;
}

const ctx = {
session: {
...session,
activeOrganizationId: session.activeOrganizationId || "",
},
user: {
...user,
role: user.role as "owner" | "member" | "admin",
},
};

const input = apiUpsertApplicationEnv.parse(req.body);

await checkServicePermissionAndAccess(
ctx,
input.applicationId,
input.redeploy
? {
envVars: ["write"],
deployment: ["create"],
}
: {
envVars: ["write"],
},
);

const result = await upsertApplicationEnvironment(input);
let redeployed = false;

if (!result.dryRun && result.changed) {
const application = await findApplicationById(input.applicationId);

await audit(ctx, {
action: "update",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});

if (input.redeploy) {
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Rebuild deployment",
descriptionLog: "Environment variables updated",
type: "redeploy",
applicationType: "application",
server: !!application.serverId,
};

if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}

await audit(ctx, {
action: "rebuild",
resourceType: "application",
resourceId: application.applicationId,
resourceName: application.appName,
});
redeployed = true;
}
}

res.status(200).json({
...result,
redeployed,
});
} catch (error) {
res.status(getErrorStatus(error)).json({ message: getErrorMessage(error) });
}
};

export default handleApplicationEnvUpsert;
4 changes: 4 additions & 0 deletions apps/dokploy/pages/api/application/env/upsert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
default,
handleApplicationEnvUpsert,
} from "../../application.env.upsert";
Loading
Loading