Skip to content
Merged
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
167 changes: 167 additions & 0 deletions apps/dokploy/__test__/compose/compose-env-merge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { parse } from "dotenv";
import { describe, expect, it } from "vitest";
import {
formatEnvValue,
mergeComposeEnv,
readComposeEnvState,
} from "@/lib/compose-env";

const HEADER = ["Defaults from ./docker-compose.yml (branch main)."];

describe("formatEnvValue", () => {
it.each([
["8080", "8080"],
["", ""],
["http://origin:80", "http://origin:80"],
["a b", "'a b'"],
["color #fff", "'color #fff'"],
["it's", `"it's"`],
[`it's "quoted"`, `\`it's "quoted"\``],
["C:\\path's", "`C:\\path's`"],
])("writes %j so dotenv reads it back unchanged", (value, formatted) => {
expect(formatEnvValue(value)).toBe(formatted);
expect(parse(`KEY=${formatted}`).KEY).toBe(value);
});
});

describe("readComposeEnvState", () => {
it("tells set, listed and absent variables apart", () => {
const stateOf = readComposeEnvState(
[
"SET=1",
"export EXPORTED=2",
"# LISTED=3",
"# BOTH=old",
"BOTH=new",
"# a note about PROSE = text",
].join("\n"),
);

expect(stateOf("SET")).toEqual({ kind: "set" });
expect(stateOf("EXPORTED")).toEqual({ kind: "set" });
expect(stateOf("LISTED")).toEqual({ kind: "listed", value: "3" });
expect(stateOf("BOTH")).toEqual({ kind: "set" });
expect(stateOf("PROSE")).toEqual({ kind: "absent" });
expect(stateOf("MISSING")).toEqual({ kind: "absent" });
});
});

describe("mergeComposeEnv", () => {
it("adds commented defaults that dotenv does not apply", () => {
const merged = mergeComposeEnv(
"",
[
{ name: "PORT", value: "8080", services: ["web"] },
{ name: "MODE", value: "off", services: ["web"] },
],
{ active: false, header: HEADER },
);

expect(merged).toBe(
[
"# Defaults from ./docker-compose.yml (branch main).",
"# PORT=8080",
"# MODE=off",
"",
].join("\n"),
);
expect(parse(merged)).toEqual({});
});

it("adds active lines that dotenv applies", () => {
const merged = mergeComposeEnv(
"",
[{ name: "MODE", value: "a b", services: ["web"] }],
{ active: true, header: HEADER },
);

expect(parse(merged)).toEqual({ MODE: "a b" });
});

it("never changes a variable that is already set", () => {
const merged = mergeComposeEnv(
"PORT=9000\nSECRET=mine",
[
{ name: "PORT", value: "8080", services: ["web"] },
{ name: "SECRET", value: "change-me", services: ["web"] },
],
{ active: true, header: HEADER },
);

expect(merged).toBe("PORT=9000\nSECRET=mine");
});

it("refreshes commented defaults in place instead of adding them again", () => {
const env = "KEEP=1\n# LIMIT=60\n# my own note\nOTHER=2\n";

expect(
mergeComposeEnv(
env,
[{ name: "LIMIT", value: "100", services: ["engine"] }],
{ active: false, header: HEADER },
),
).toBe("KEEP=1\n# LIMIT=100\n# my own note\nOTHER=2\n");

expect(
mergeComposeEnv(
env,
[{ name: "LIMIT", value: "100", services: ["engine"] }],
{ active: true, header: HEADER },
),
).toBe("KEEP=1\nLIMIT=100\n# my own note\nOTHER=2\n");
});

it("appends after the existing text and groups lines by service", () => {
const merged = mergeComposeEnv(
"KEEP=1\n\n",
[
{ name: "HTTP_PORT", value: "8080", services: ["caddy"] },
{
name: "REDIS_URL",
value: "redis://redis:6379",
services: ["engine"],
},
{ name: "PATHS", value: "/hook", services: ["caddy", "engine"] },
{ name: "TLS", value: "off", services: ["caddy"] },
{ name: "NETWORK", value: "edge", services: [] },
],
{
active: false,
header: [...HEADER, "Remove the # to override a default."],
},
);

expect(merged).toBe(
[
"KEEP=1",
"",
"# Defaults from ./docker-compose.yml (branch main).",
"# Remove the # to override a default.",
"",
"# Used by caddy",
"# HTTP_PORT=8080",
"# TLS=off",
"",
"# Used by engine",
"# REDIS_URL=redis://redis:6379",
"",
"# Used by caddy, engine",
"# PATHS=/hook",
"",
"# Not tied to a service",
"# NETWORK=edge",
"",
].join("\n"),
);
expect(parse(merged)).toEqual({ KEEP: "1" });
});

it("leaves the text alone when nothing is left to add", () => {
expect(
mergeComposeEnv("A=1", [{ name: "A", value: "2", services: [] }], {
active: false,
header: HEADER,
}),
).toBe("A=1");
});
});
225 changes: 225 additions & 0 deletions apps/dokploy/__test__/compose/compose-variables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import {
extractComposeVariables,
findVariableReferences,
} from "@dokploy/server/utils/docker/compose-variables";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";

const variablesOf = (yaml: string) =>
extractComposeVariables(parse(yaml, { maxAliasCount: 10000 }));

describe("findVariableReferences", () => {
it("reads every interpolation form compose supports", () => {
expect(
findVariableReferences(
"${A} ${B:-one} ${C-two} ${D:?need D} ${E?need E} ${F:+on} $G",
),
).toEqual([
{ name: "A", operator: null, value: null },
{ name: "B", operator: ":-", value: "one" },
{ name: "C", operator: "-", value: "two" },
{ name: "D", operator: ":?", value: "need D" },
{ name: "E", operator: "?", value: "need E" },
{ name: "F", operator: ":+", value: "on" },
{ name: "G", operator: null, value: null },
]);
});

it("treats $$ as a literal dollar sign", () => {
expect(findVariableReferences("echo $$HOME $${NOT_A_VAR} ${REAL}")).toEqual(
[{ name: "REAL", operator: null, value: null }],
);
});

it("keeps nested defaults whole and reads the variables inside them", () => {
expect(
findVariableReferences("${URL:-http://${HOST:-localhost}:80}"),
).toEqual([
{
name: "URL",
operator: ":-",
value: "http://${HOST:-localhost}:80",
},
{ name: "HOST", operator: ":-", value: "localhost" },
]);
});

it("ignores text that only looks like a reference", () => {
expect(findVariableReferences("$ ${ ${1A} ${UNCLOSED:-x ${A B}")).toEqual(
[],
);
});
});

describe("extractComposeVariables", () => {
it("lists variables with their defaults and the services using them", () => {
const variables = variablesOf(`
services:
caddy:
image: caddy
ports:
- "\${HTTP_PORT:-8080}:80"
environment:
MODE: \${MODE:-off}
PATHS: \${PATHS:-/payments/webhook}
engine:
image: engine
environment:
PATHS: \${PATHS:-/payments/webhook}
TOKEN: \${ADMIN_TOKEN:-}
HOST: \${DB_HOST}
REQUIRED: \${DB_PASSWORD:?set a password}
`);

expect(variables).toEqual([
{
name: "HTTP_PORT",
defaultValue: "8080",
required: false,
conflictingDefaults: false,
services: ["caddy"],
secret: false,
placeholder: false,
},
{
name: "MODE",
defaultValue: "off",
required: false,
conflictingDefaults: false,
services: ["caddy"],
secret: false,
placeholder: false,
},
{
name: "PATHS",
defaultValue: "/payments/webhook",
required: false,
conflictingDefaults: false,
services: ["caddy", "engine"],
secret: false,
placeholder: false,
},
{
name: "ADMIN_TOKEN",
defaultValue: "",
required: false,
conflictingDefaults: false,
services: ["engine"],
secret: true,
placeholder: false,
},
{
name: "DB_HOST",
defaultValue: null,
required: false,
conflictingDefaults: false,
services: ["engine"],
secret: false,
placeholder: false,
},
{
name: "DB_PASSWORD",
defaultValue: null,
required: true,
conflictingDefaults: false,
services: ["engine"],
secret: true,
placeholder: false,
},
]);
});

it("flags defaults that ask to be replaced", () => {
const variables = variablesOf(`
services:
engine:
environment:
SALT: \${HASH_SALT:-change-me-hash-salt}
ADMIN: \${CAP_ADMIN_KEY:-replace_me}
TTL: \${PASS_TTL_SECONDS:-1800}
`);

expect(
variables.map(({ name, secret, placeholder }) => [
name,
secret,
placeholder,
]),
).toEqual([
["HASH_SALT", false, true],
["CAP_ADMIN_KEY", true, true],
["PASS_TTL_SECONDS", true, false],
]);
});

it("skips variables that appear only in YAML comments", () => {
const variables = variablesOf(`
services:
cap:
# The dashboard listens on 127.0.0.1:\${DASHBOARD_PORT} of the host.
ports:
- "127.0.0.1:\${DASHBOARD_PORT:-3100}:3000"
# \${ONLY_IN_A_COMMENT:-x}
`);

expect(
variables.map(({ name, defaultValue }) => [name, defaultValue]),
).toEqual([["DASHBOARD_PORT", "3100"]]);
});

it("follows anchors into the services that use them", () => {
const variables = variablesOf(`
x-common: &common
environment:
LOG_LEVEL: \${LOG_LEVEL:-info}
services:
api:
<<: *common
image: api
worker:
<<: *common
image: worker
`);

expect(variables).toMatchObject([
{ name: "LOG_LEVEL", defaultValue: "info", services: ["api", "worker"] },
]);
});

it("flags a variable whose defaults differ between places", () => {
const variables = variablesOf(`
services:
a:
environment:
LIMIT: \${LIMIT:-60}
b:
environment:
LIMIT: \${LIMIT:-120}
`);

expect(variables).toMatchObject([
{ name: "LIMIT", defaultValue: "60", conflictingDefaults: true },
]);
});

it("keeps variables outside services and leaves out the ones Dokploy sets", () => {
const variables = variablesOf(`
services:
web:
container_name: \${APP_NAME}-web
image: \${COMPOSE_PROJECT_NAME}:latest
networks:
edge:
name: \${EDGE_NETWORK:-edge}
`);

expect(variables).toMatchObject([
{ name: "EDGE_NETWORK", defaultValue: "edge", services: [] },
]);
});

it("returns nothing for an empty or non-mapping file", () => {
expect(extractComposeVariables(null)).toEqual([]);
expect(extractComposeVariables(["${A}"])).toEqual([]);
});
});
Loading