mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge 16d6c32ece into 853ca33659
This commit is contained in:
commit
5a8920bc6f
4
.gitignore
vendored
4
.gitignore
vendored
@ -46,4 +46,6 @@ yarn-error.log*
|
||||
.db
|
||||
|
||||
.playwright-*
|
||||
.credentials
|
||||
.credentials
|
||||
.cursor/handoff.md
|
||||
.env.production
|
||||
|
||||
@ -7,6 +7,17 @@ vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/registry", () => ({
|
||||
findRegistryByIdWithCredentials: vi.fn().mockResolvedValue({
|
||||
registryUrl: "registry.example.com",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
}),
|
||||
safeDockerLoginCommand: vi
|
||||
.fn()
|
||||
.mockReturnValue('echo "docker login stub"'),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
sourceType: "raw",
|
||||
@ -17,6 +28,8 @@ const baseCompose = {
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
buildRegistryId: null,
|
||||
env: "",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
@ -49,4 +62,20 @@ describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||
expect(command).toContain("compose -p my-app");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
|
||||
it("uses prebuilt pull/up when buildServerId is set", async () => {
|
||||
const command = await getBuildComposeCommand(
|
||||
{
|
||||
...baseCompose,
|
||||
composeType: "docker-compose",
|
||||
buildServerId: "build-server-1",
|
||||
buildRegistryId: "registry-1",
|
||||
},
|
||||
{ prebuilt: true },
|
||||
);
|
||||
|
||||
expect(command).toContain("pull");
|
||||
expect(command).toContain("--no-build");
|
||||
expect(command).toContain("docker login stub");
|
||||
});
|
||||
});
|
||||
|
||||
86
apps/dokploy/__test__/compose/build-registry-images.test.ts
Normal file
86
apps/dokploy/__test__/compose/build-registry-images.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
applyBuildRegistryImages,
|
||||
hasBuildableServices,
|
||||
} from "@dokploy/server/utils/docker/domain";
|
||||
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
|
||||
import type { Registry } from "@dokploy/server/services/registry";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const registry = {
|
||||
registryId: "reg-1",
|
||||
registryName: "Test Registry",
|
||||
registryUrl: "registry.example.com",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
imagePrefix: "myorg",
|
||||
registryType: "cloud" as const,
|
||||
organizationId: "org-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe("applyBuildRegistryImages", () => {
|
||||
it("rewrites image tags for services with build:", () => {
|
||||
const spec: ComposeSpecification = {
|
||||
services: {
|
||||
api: {
|
||||
build: "./api",
|
||||
image: "local-api:latest",
|
||||
},
|
||||
db: {
|
||||
image: "postgres:16",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyBuildRegistryImages(spec, registry, "my-compose");
|
||||
|
||||
expect(result.services?.api?.image).toBe(
|
||||
"registry.example.com/myorg/my-compose-api:latest",
|
||||
);
|
||||
expect(result.services?.api?.build).toBe("./api");
|
||||
expect(result.services?.db?.image).toBe("postgres:16");
|
||||
});
|
||||
|
||||
it("lowercases and sanitizes service names in image tags", () => {
|
||||
const spec: ComposeSpecification = {
|
||||
services: {
|
||||
"MyService": {
|
||||
build: ".",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyBuildRegistryImages(
|
||||
spec,
|
||||
registry,
|
||||
"App-Name",
|
||||
);
|
||||
|
||||
expect(result.services?.MyService?.image).toBe(
|
||||
"registry.example.com/myorg/app-name-myservice:latest",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasBuildableServices", () => {
|
||||
it("returns true when at least one service has build:", () => {
|
||||
expect(
|
||||
hasBuildableServices({
|
||||
services: {
|
||||
web: { image: "nginx" },
|
||||
api: { build: "." },
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no service has build:", () => {
|
||||
expect(
|
||||
hasBuildableServices({
|
||||
services: {
|
||||
db: { image: "postgres:16" },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "my-app",
|
||||
sourceType: "raw" as const,
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
buildServerId: null,
|
||||
} as unknown as Parameters<typeof createCommand>[0];
|
||||
|
||||
describe("createCommand prebuilt remote build", () => {
|
||||
it("uses --build when not prebuilt", () => {
|
||||
const command = createCommand(base, { prebuilt: false });
|
||||
expect(command).toContain("up -d --build --remove-orphans");
|
||||
expect(command).not.toContain("--no-build");
|
||||
});
|
||||
|
||||
it("uses pull and --no-build when prebuilt", () => {
|
||||
const command = createCommand(base, { prebuilt: true });
|
||||
// First segment is prefixed with `docker ` at exec time; chained segment
|
||||
// must already include `docker compose` or it runs as bare `compose`.
|
||||
expect(command).toMatch(
|
||||
/^compose -p .+ pull && docker compose -p .+ up -d --no-build --remove-orphans$/,
|
||||
);
|
||||
expect(command).not.toContain("--build");
|
||||
});
|
||||
|
||||
it("keeps stack deploy with registry auth when prebuilt", () => {
|
||||
const command = createCommand(
|
||||
{ ...base, composeType: "stack" },
|
||||
{ prebuilt: true },
|
||||
);
|
||||
expect(command).toContain("stack deploy");
|
||||
expect(command).toContain("--with-registry-auth");
|
||||
});
|
||||
});
|
||||
@ -34,9 +34,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
type ApplicationProps = {
|
||||
serviceType: "application";
|
||||
applicationId: string;
|
||||
}
|
||||
};
|
||||
|
||||
type ComposeProps = {
|
||||
serviceType: "compose";
|
||||
composeId: string;
|
||||
};
|
||||
|
||||
type Props = ApplicationProps | ComposeProps;
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@ -45,13 +53,11 @@ const schema = z
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Both empty/none is valid
|
||||
const buildServerIsNone =
|
||||
!data.buildServerId || data.buildServerId === "none";
|
||||
const buildRegistryIsNone =
|
||||
!data.buildRegistryId || data.buildRegistryId === "none";
|
||||
|
||||
// Both should be either filled or empty
|
||||
if (buildServerIsNone && buildRegistryIsNone) return true;
|
||||
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
||||
|
||||
@ -60,21 +66,42 @@ const schema = z
|
||||
{
|
||||
message:
|
||||
"Both Build Server and Build Registry must be selected together, or both set to None",
|
||||
path: ["buildServerId"], // Show error on buildServerId field
|
||||
path: ["buildServerId"],
|
||||
},
|
||||
);
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
export const ShowBuildServer = (props: Props) => {
|
||||
const isCompose = props.serviceType === "compose";
|
||||
const serviceId = isCompose ? props.composeId : props.applicationId;
|
||||
|
||||
const applicationQuery = api.application.one.useQuery(
|
||||
{ applicationId: props.serviceType === "application" ? props.applicationId : "" },
|
||||
{ enabled: props.serviceType === "application" && !!props.applicationId },
|
||||
);
|
||||
const composeQuery = api.compose.one.useQuery(
|
||||
{ composeId: props.serviceType === "compose" ? props.composeId : "" },
|
||||
{ enabled: props.serviceType === "compose" && !!props.composeId },
|
||||
);
|
||||
|
||||
const data =
|
||||
props.serviceType === "application"
|
||||
? applicationQuery.data
|
||||
: composeQuery.data;
|
||||
const refetch =
|
||||
props.serviceType === "application"
|
||||
? applicationQuery.refetch
|
||||
: composeQuery.refetch;
|
||||
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||
const updateApplication = api.application.update.useMutation();
|
||||
const updateCompose = api.compose.update.useMutation();
|
||||
const isPending = isCompose
|
||||
? updateCompose.isPending
|
||||
: updateApplication.isPending;
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
@ -94,24 +121,34 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildServerId:
|
||||
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||
? null
|
||||
: formData?.buildServerId,
|
||||
buildRegistryId:
|
||||
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||
? null
|
||||
: formData?.buildRegistryId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build Server Settings Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating build server settings");
|
||||
});
|
||||
const buildServerId =
|
||||
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||
? null
|
||||
: formData?.buildServerId;
|
||||
const buildRegistryId =
|
||||
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||
? null
|
||||
: formData?.buildRegistryId;
|
||||
|
||||
try {
|
||||
if (isCompose) {
|
||||
await updateCompose.mutateAsync({
|
||||
composeId: serviceId,
|
||||
buildServerId,
|
||||
buildRegistryId,
|
||||
});
|
||||
} else {
|
||||
await updateApplication.mutateAsync({
|
||||
applicationId: serviceId,
|
||||
buildServerId,
|
||||
buildRegistryId,
|
||||
});
|
||||
}
|
||||
toast.success("Build Server Settings Updated");
|
||||
await refetch();
|
||||
} catch {
|
||||
toast.error("Error updating build server settings");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -122,7 +159,8 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
<div>
|
||||
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a dedicated server for building your application.
|
||||
Configure a dedicated server for building your{" "}
|
||||
{isCompose ? "compose service" : "application"}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@ -130,10 +168,19 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
Build servers offload the build process from your deployment servers.
|
||||
Select a build server and registry to use for building your
|
||||
application.
|
||||
Select a build server and registry to use for building your service.
|
||||
</AlertBlock>
|
||||
|
||||
{isCompose ? (
|
||||
<AlertBlock type="info">
|
||||
For compose services, only services with a{" "}
|
||||
<code className="text-xs">build:</code> section are built on the
|
||||
build server and pushed to the registry. Image-only services are
|
||||
unchanged. Custom deploy commands should not include{" "}
|
||||
<code className="text-xs">--build</code> when using a build server.
|
||||
</AlertBlock>
|
||||
) : null}
|
||||
|
||||
<AlertBlock type="info">
|
||||
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||
wait a few seconds for the deployment server to download the image.
|
||||
@ -175,7 +222,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If setting to "none", also reset build registry to "none"
|
||||
if (value === "none") {
|
||||
form.setValue("buildRegistryId", "none");
|
||||
}
|
||||
@ -215,7 +261,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a build server to handle the build process for this
|
||||
application.
|
||||
service.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -231,7 +277,6 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If setting to "none", also reset build server to "none"
|
||||
if (value === "none") {
|
||||
form.setValue("buildServerId", "none");
|
||||
}
|
||||
|
||||
@ -136,7 +136,10 @@ export function ShowDeploymentsTable() {
|
||||
d.compose?.server?.name ??
|
||||
"";
|
||||
const buildServerName =
|
||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
|
||||
d.buildServer?.name ??
|
||||
d.application?.buildServer?.name ??
|
||||
d.compose?.buildServer?.name ??
|
||||
"";
|
||||
if (!info) return false;
|
||||
return (
|
||||
info.name.toLowerCase().includes(q) ||
|
||||
@ -296,10 +299,14 @@ export function ShowDeploymentsTable() {
|
||||
d.compose?.server?.serverType ??
|
||||
null;
|
||||
const buildServerName =
|
||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
|
||||
d.buildServer?.name ??
|
||||
d.application?.buildServer?.name ??
|
||||
d.compose?.buildServer?.name ??
|
||||
null;
|
||||
const buildServerType =
|
||||
d.buildServer?.serverType ??
|
||||
d.application?.buildServer?.serverType ??
|
||||
d.compose?.buildServer?.serverType ??
|
||||
null;
|
||||
const showBuild =
|
||||
buildServerName != null && buildServerName !== serverName;
|
||||
|
||||
4
apps/dokploy/drizzle/0197_sharp_arclight.sql
Normal file
4
apps/dokploy/drizzle/0197_sharp_arclight.sql
Normal file
@ -0,0 +1,4 @@
|
||||
ALTER TABLE "compose" ADD COLUMN "buildServerId" text;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD CONSTRAINT "compose_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD CONSTRAINT "compose_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "92a9010e-bc5f-4c9f-b97e-f2867a602e90",
|
||||
"prevId": "633b82a2-0321-4ef4-ae09-7a8861830873",
|
||||
"id": "bf16a3c7-9d5d-4c2f-83ce-f87c7878dd23",
|
||||
"prevId": "c9ad226a-7a8f-4c3b-8f04-fef6bccf9bfb",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@ -2690,6 +2690,13 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"pullImages": {
|
||||
"name": "pullImages",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"triggerType": {
|
||||
"name": "triggerType",
|
||||
"type": "triggerType",
|
||||
@ -2760,6 +2767,18 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"buildServerId": {
|
||||
"name": "buildServerId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"buildRegistryId": {
|
||||
"name": "buildRegistryId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"serviceNetworks": {
|
||||
"name": "serviceNetworks",
|
||||
"type": "jsonb",
|
||||
@ -2860,6 +2879,32 @@
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"compose_buildServerId_server_serverId_fk": {
|
||||
"name": "compose_buildServerId_server_serverId_fk",
|
||||
"tableFrom": "compose",
|
||||
"tableTo": "server",
|
||||
"columnsFrom": [
|
||||
"buildServerId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"serverId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"compose_buildRegistryId_registry_registryId_fk": {
|
||||
"name": "compose_buildRegistryId_registry_registryId_fk",
|
||||
"tableFrom": "compose",
|
||||
"tableTo": "registry",
|
||||
"columnsFrom": [
|
||||
"buildRegistryId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"registryId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
@ -7983,6 +8028,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"domain_verified": {
|
||||
"name": "domain_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
@ -8351,6 +8403,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "ARRAY[]::text[]"
|
||||
},
|
||||
"onboardingCompletedAt": {
|
||||
"name": "onboardingCompletedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@ -8797,7 +8855,7 @@
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb"
|
||||
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"footerText\":null,\"ogImageUrl\":null}'::jsonb"
|
||||
},
|
||||
"remoteServersOnly": {
|
||||
"name": "remoteServersOnly",
|
||||
@ -8946,7 +9004,10 @@
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"cloudflare",
|
||||
"route53"
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak",
|
||||
"ovh"
|
||||
]
|
||||
},
|
||||
"public.domainType": {
|
||||
@ -9126,6 +9187,7 @@
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"aws-parameter-store",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
@ -9143,4 +9205,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1380,6 +1380,13 @@
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1787888847584,
|
||||
"tag": "0197_sharp_arclight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -425,7 +425,10 @@ const Service = (
|
||||
id={applicationId}
|
||||
type="application"
|
||||
/>
|
||||
<ShowBuildServer applicationId={applicationId} />
|
||||
<ShowBuildServer
|
||||
serviceType="application"
|
||||
applicationId={applicationId}
|
||||
/>
|
||||
<ShowResources id={applicationId} type="application" />
|
||||
<ShowVolumes id={applicationId} type="application" />
|
||||
<AssignNetworks id={applicationId} type="application" />
|
||||
|
||||
@ -21,6 +21,7 @@ import { ShowIconSettings } from "@/components/dashboard/application/icon/show-i
|
||||
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
|
||||
@ -439,6 +440,10 @@ const Service = (
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<AddCommandCompose composeId={composeId} />
|
||||
<ShowBuildServer
|
||||
serviceType="compose"
|
||||
composeId={composeId}
|
||||
/>
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
<AssignComposeNetworks composeId={composeId} />
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
findDomainsByComposeId,
|
||||
findEnvironmentById,
|
||||
findProjectById,
|
||||
findRegistryById,
|
||||
findServerById,
|
||||
getAccessibleServerIds,
|
||||
getComposeContainer,
|
||||
@ -197,7 +198,40 @@ export const composeRouter = createTRPCRouter({
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["create"],
|
||||
});
|
||||
const updated = await updateCompose(input.composeId, input);
|
||||
|
||||
const compose = await findComposeById(input.composeId);
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this compose",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.buildServerId) {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
if (!accessibleIds.has(input.buildServerId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this build server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.buildRegistryId) {
|
||||
const reg = await findRegistryById(input.buildRegistryId);
|
||||
if (reg.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this build registry",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { composeId, ...rest } = input;
|
||||
const updated = await updateCompose(composeId, rest);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "compose",
|
||||
@ -293,7 +327,10 @@ export const composeRouter = createTRPCRouter({
|
||||
deployment: ["create"],
|
||||
});
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await clearOldDeployments(compose.appName, compose.serverId);
|
||||
await clearOldDeployments(
|
||||
compose.appName,
|
||||
compose.buildServerId || compose.serverId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "compose",
|
||||
@ -309,7 +346,10 @@ export const composeRouter = createTRPCRouter({
|
||||
deployment: ["cancel"],
|
||||
});
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await killDockerBuild("compose", compose.serverId);
|
||||
await killDockerBuild(
|
||||
"compose",
|
||||
compose.buildServerId || compose.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
loadServices: protectedProcedure
|
||||
|
||||
@ -265,6 +265,7 @@ export const deploymentRouter = createTRPCRouter({
|
||||
|
||||
const command = `tail -n ${input.tail} "${deployment.logPath}" 2>/dev/null || echo ""`;
|
||||
const serverId =
|
||||
deployment.buildServerId ||
|
||||
deployment.serverId ||
|
||||
deployment.schedule?.serverId ||
|
||||
deployment.application?.serverId ||
|
||||
|
||||
18
package.json
18
package.json
@ -61,22 +61,6 @@
|
||||
"typescript",
|
||||
"drizzle-kit"
|
||||
]
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@scarf/scarf",
|
||||
"@tree-sitter-grammars/tree-sitter-yaml",
|
||||
"bcrypt",
|
||||
"better-sqlite3",
|
||||
"core-js-pure",
|
||||
"cpu-features",
|
||||
"esbuild",
|
||||
"msgpackr-extract",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"sharp",
|
||||
"ssh2",
|
||||
"tree-sitter",
|
||||
"tree-sitter-json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { github } from "./github";
|
||||
import { gitlab } from "./gitlab";
|
||||
import { mounts } from "./mount";
|
||||
import { patch } from "./patch";
|
||||
import { registry } from "./registry";
|
||||
import { schedules } from "./schedule";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
@ -123,6 +124,15 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
buildServerId: text("buildServerId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
buildRegistryId: text("buildRegistryId").references(
|
||||
() => registry.registryId,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
serviceNetworks: jsonb("serviceNetworks")
|
||||
.$type<
|
||||
Array<{
|
||||
@ -165,6 +175,17 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
server: one(server, {
|
||||
fields: [compose.serverId],
|
||||
references: [server.serverId],
|
||||
relationName: "composeServer",
|
||||
}),
|
||||
buildServer: one(server, {
|
||||
fields: [compose.buildServerId],
|
||||
references: [server.serverId],
|
||||
relationName: "composeBuildServer",
|
||||
}),
|
||||
buildRegistry: one(registry, {
|
||||
fields: [compose.buildRegistryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "composeBuildRegistry",
|
||||
}),
|
||||
backups: many(backups),
|
||||
schedules: many(schedules),
|
||||
|
||||
@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { applications } from "./application";
|
||||
import { compose } from "./compose";
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
@ -42,6 +43,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
||||
rollbackApplications: many(applications, {
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
buildComposes: many(compose, {
|
||||
relationName: "composeBuildRegistry",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Registry usernames should NOT be lowercased.
|
||||
|
||||
@ -118,7 +118,12 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
buildApplications: many(applications, {
|
||||
relationName: "applicationBuildServer",
|
||||
}),
|
||||
compose: many(compose),
|
||||
compose: many(compose, {
|
||||
relationName: "composeServer",
|
||||
}),
|
||||
buildCompose: many(compose, {
|
||||
relationName: "composeBuildServer",
|
||||
}),
|
||||
libsql: many(libsql),
|
||||
redis: many(redis),
|
||||
mariadb: many(mariadb),
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { promises as fsPromises } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@ -7,7 +8,7 @@ import {
|
||||
cleanAppName,
|
||||
compose,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { getBuildComposeCommand, getComposeRemoteBuildCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { randomizeSpecificationFile } from "@dokploy/server/utils/docker/compose";
|
||||
import {
|
||||
cloneCompose,
|
||||
@ -144,6 +145,8 @@ export const findComposeById = async (composeId: string) => {
|
||||
},
|
||||
},
|
||||
server: true,
|
||||
buildServer: true,
|
||||
buildRegistry: { columns: { password: false } },
|
||||
backups: {
|
||||
with: {
|
||||
destination: {
|
||||
@ -225,6 +228,139 @@ export const updateCompose = async (
|
||||
return composeResult[0];
|
||||
};
|
||||
|
||||
type ComposeEntity = Awaited<ReturnType<typeof findComposeById>> & {
|
||||
type: "compose";
|
||||
};
|
||||
|
||||
const ensureLogDirectory = async (
|
||||
serverId: string | null,
|
||||
logPath: string,
|
||||
) => {
|
||||
const logDir = dirname(logPath);
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, `mkdir -p ${quote([logDir])}`);
|
||||
return;
|
||||
}
|
||||
await fsPromises.mkdir(logDir, { recursive: true });
|
||||
};
|
||||
|
||||
const runWithLog = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
logPath: string,
|
||||
) => {
|
||||
await ensureLogDirectory(serverId, logPath);
|
||||
const commandWithLog = `(${command}) >> ${quote([logPath])} 2>&1`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
};
|
||||
|
||||
const appendDeployLogsIfSplit = async (
|
||||
deployTarget: string | null,
|
||||
deployLogPath: string,
|
||||
logTarget: string | null,
|
||||
mainLogPath: string,
|
||||
) => {
|
||||
if (logTarget === deployTarget) return;
|
||||
|
||||
await appendLogFromTarget(
|
||||
deployTarget,
|
||||
deployLogPath,
|
||||
logTarget,
|
||||
mainLogPath,
|
||||
"Deploy phase",
|
||||
);
|
||||
};
|
||||
|
||||
const appendLogFromTarget = async (
|
||||
fromServerId: string | null,
|
||||
fromLogPath: string,
|
||||
toServerId: string | null,
|
||||
toLogPath: string,
|
||||
sectionLabel: string,
|
||||
) => {
|
||||
let content = "";
|
||||
if (fromServerId) {
|
||||
const result = await execAsyncRemote(
|
||||
fromServerId,
|
||||
`cat ${quote([fromLogPath])} 2>/dev/null || true`,
|
||||
);
|
||||
content = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(
|
||||
`cat ${quote([fromLogPath])} 2>/dev/null || true`,
|
||||
);
|
||||
content = result.stdout;
|
||||
}
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
const section = `\n=== ${sectionLabel} ===\n${content}`;
|
||||
const encoded = encodeBase64(section);
|
||||
const appendCommand = `echo "${encoded}" | base64 -d >> ${quote([toLogPath])}`;
|
||||
if (toServerId) {
|
||||
await execAsyncRemote(toServerId, appendCommand);
|
||||
} else {
|
||||
await execAsync(appendCommand);
|
||||
}
|
||||
};
|
||||
|
||||
const cloneComposeSource = async (
|
||||
compose: ComposeEntity,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
const entity = { ...compose, serverId, type: "compose" as const };
|
||||
let command = "set -e;";
|
||||
if (compose.sourceType === "github") {
|
||||
command += await cloneGithubRepository(entity);
|
||||
} else if (compose.sourceType === "gitlab") {
|
||||
command += await cloneGitlabRepository(entity);
|
||||
} else if (compose.sourceType === "bitbucket") {
|
||||
command += await cloneBitbucketRepository(entity);
|
||||
} else if (compose.sourceType === "git") {
|
||||
command += await cloneGitRepository(entity);
|
||||
} else if (compose.sourceType === "gitea") {
|
||||
command += await cloneGiteaRepository(entity);
|
||||
} else if (compose.sourceType === "raw") {
|
||||
command += getCreateComposeFileCommand(entity);
|
||||
}
|
||||
return command;
|
||||
};
|
||||
|
||||
const applyComposePatches = async (
|
||||
compose: ComposeEntity,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
if (compose.sourceType === "raw") {
|
||||
return "";
|
||||
}
|
||||
return generateApplyPatchesCommand({
|
||||
id: compose.composeId,
|
||||
type: "compose",
|
||||
serverId,
|
||||
});
|
||||
};
|
||||
|
||||
const cloneAndPatchOnTarget = async (
|
||||
compose: ComposeEntity,
|
||||
targetServerId: string | null,
|
||||
logPath: string,
|
||||
options?: { skipClone?: boolean },
|
||||
) => {
|
||||
if (!options?.skipClone) {
|
||||
const command = await cloneComposeSource(compose, targetServerId);
|
||||
await runWithLog(targetServerId, command, logPath);
|
||||
}
|
||||
|
||||
const patchCommand = await applyComposePatches(compose, targetServerId);
|
||||
if (patchCommand) {
|
||||
await runWithLog(targetServerId, `set -e;${patchCommand}`, logPath);
|
||||
}
|
||||
};
|
||||
|
||||
export const deployCompose = async ({
|
||||
composeId,
|
||||
titleLog = "Manual deployment",
|
||||
@ -237,6 +373,10 @@ export const deployCompose = async ({
|
||||
freshVolumes?: boolean;
|
||||
}) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
const buildTarget = compose.buildServerId ?? null;
|
||||
const deployTarget = compose.serverId ?? null;
|
||||
const logTarget = buildTarget ?? deployTarget;
|
||||
const usesRemoteBuild = Boolean(buildTarget && compose.buildRegistryId);
|
||||
|
||||
const buildLink = `${await getDokployUrl()}/dashboard/project/${
|
||||
compose.environment.projectId
|
||||
@ -247,65 +387,64 @@ export const deployCompose = async ({
|
||||
description: descriptionLog,
|
||||
});
|
||||
|
||||
const entity = {
|
||||
...compose,
|
||||
type: "compose" as const,
|
||||
};
|
||||
|
||||
const logErrorToTarget = async (error: unknown) => {
|
||||
let command = "";
|
||||
if (!(error instanceof ExecError)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const encodedMessage = encodeBase64(message);
|
||||
command += `echo "${encodedMessage}" | base64 -d >> ${quote([deployment.logPath])};`;
|
||||
}
|
||||
command += `echo "\nError occurred ❌, check the logs for details." >> ${quote([deployment.logPath])};`;
|
||||
if (logTarget) {
|
||||
await execAsyncRemote(logTarget, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
const deployLogPath =
|
||||
logTarget !== deployTarget
|
||||
? `${deployment.logPath}.deploy`
|
||||
: deployment.logPath;
|
||||
|
||||
try {
|
||||
const entity = {
|
||||
...compose,
|
||||
type: "compose" as const,
|
||||
};
|
||||
let command = "set -e;";
|
||||
if (compose.sourceType === "github") {
|
||||
command += await cloneGithubRepository(entity);
|
||||
} else if (compose.sourceType === "gitlab") {
|
||||
command += await cloneGitlabRepository(entity);
|
||||
} else if (compose.sourceType === "bitbucket") {
|
||||
command += await cloneBitbucketRepository(entity);
|
||||
} else if (compose.sourceType === "git") {
|
||||
command += await cloneGitRepository(entity);
|
||||
} else if (compose.sourceType === "gitea") {
|
||||
command += await cloneGiteaRepository(entity);
|
||||
} else if (compose.sourceType === "raw") {
|
||||
command += getCreateComposeFileCommand(entity);
|
||||
if (usesRemoteBuild && buildTarget) {
|
||||
await cloneAndPatchOnTarget(entity, buildTarget, deployment.logPath);
|
||||
const buildCommand = await getComposeRemoteBuildCommand(
|
||||
entity,
|
||||
buildTarget,
|
||||
);
|
||||
await runWithLog(buildTarget, buildCommand, deployment.logPath);
|
||||
}
|
||||
|
||||
let commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
if (compose.sourceType !== "raw") {
|
||||
command = "set -e;";
|
||||
command += await generateApplyPatchesCommand({
|
||||
id: compose.composeId,
|
||||
type: "compose",
|
||||
serverId: compose.serverId,
|
||||
});
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
if (usesRemoteBuild && deployTarget !== buildTarget) {
|
||||
await cloneAndPatchOnTarget(entity, deployTarget, deployLogPath);
|
||||
} else if (!usesRemoteBuild) {
|
||||
await cloneAndPatchOnTarget(entity, deployTarget, deployment.logPath);
|
||||
}
|
||||
|
||||
if (freshVolumes && compose.composeType === "docker-compose") {
|
||||
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
|
||||
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, downWithLog);
|
||||
} else {
|
||||
await execAsync(downWithLog);
|
||||
}
|
||||
await runWithLog(deployTarget, downCommand, deployLogPath);
|
||||
}
|
||||
|
||||
command = "set -e;";
|
||||
command += await getBuildComposeCommand(entity);
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
const deployCommand = await getBuildComposeCommand(entity, {
|
||||
prebuilt: usesRemoteBuild,
|
||||
targetServerId: deployTarget,
|
||||
});
|
||||
await runWithLog(deployTarget, deployCommand, deployLogPath);
|
||||
|
||||
await appendDeployLogsIfSplit(
|
||||
deployTarget,
|
||||
deployLogPath,
|
||||
logTarget,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateCompose(composeId, {
|
||||
@ -322,21 +461,17 @@ export const deployCompose = async ({
|
||||
environmentName: compose.environment.name,
|
||||
});
|
||||
} catch (error) {
|
||||
let command = "";
|
||||
|
||||
// Only log details for non-ExecError errors
|
||||
if (!(error instanceof ExecError)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const encodedMessage = encodeBase64(message);
|
||||
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
|
||||
}
|
||||
|
||||
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
try {
|
||||
await appendDeployLogsIfSplit(
|
||||
deployTarget,
|
||||
deployLogPath,
|
||||
logTarget,
|
||||
deployment.logPath,
|
||||
);
|
||||
} catch {
|
||||
// Best effort: deploy logs may not exist yet.
|
||||
}
|
||||
await logErrorToTarget(error);
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
await updateCompose(composeId, {
|
||||
composeStatus: "error",
|
||||
@ -379,6 +514,10 @@ export const rebuildCompose = async ({
|
||||
freshVolumes?: boolean;
|
||||
}) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
const buildTarget = compose.buildServerId ?? null;
|
||||
const deployTarget = compose.serverId ?? null;
|
||||
const logTarget = buildTarget ?? deployTarget;
|
||||
const usesRemoteBuild = Boolean(buildTarget && compose.buildRegistryId);
|
||||
|
||||
const deployment = await createDeploymentCompose({
|
||||
composeId: composeId,
|
||||
@ -386,70 +525,95 @@ export const rebuildCompose = async ({
|
||||
description: descriptionLog,
|
||||
});
|
||||
|
||||
const entity = {
|
||||
...compose,
|
||||
type: "compose" as const,
|
||||
};
|
||||
|
||||
const deployLogPath =
|
||||
logTarget !== deployTarget
|
||||
? `${deployment.logPath}.deploy`
|
||||
: deployment.logPath;
|
||||
|
||||
try {
|
||||
let command = "set -e;";
|
||||
if (compose.sourceType === "raw") {
|
||||
command += getCreateComposeFileCommand(compose);
|
||||
}
|
||||
|
||||
let commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
|
||||
if (compose.sourceType !== "raw") {
|
||||
command = "set -e;";
|
||||
command += await generateApplyPatchesCommand({
|
||||
id: compose.composeId,
|
||||
type: "compose",
|
||||
serverId: compose.serverId,
|
||||
});
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
if (usesRemoteBuild && buildTarget) {
|
||||
if (compose.sourceType === "raw") {
|
||||
const rawCommand = getCreateComposeFileCommand(entity);
|
||||
await runWithLog(buildTarget, rawCommand, deployment.logPath);
|
||||
}
|
||||
await cloneAndPatchOnTarget(entity, buildTarget, deployment.logPath, {
|
||||
skipClone: true,
|
||||
});
|
||||
const buildCommand = await getComposeRemoteBuildCommand(
|
||||
entity,
|
||||
buildTarget,
|
||||
);
|
||||
await runWithLog(buildTarget, buildCommand, deployment.logPath);
|
||||
}
|
||||
|
||||
if (usesRemoteBuild && deployTarget !== buildTarget) {
|
||||
if (compose.sourceType === "raw") {
|
||||
const rawCommand = getCreateComposeFileCommand(entity);
|
||||
await runWithLog(deployTarget, rawCommand, deployLogPath);
|
||||
}
|
||||
await cloneAndPatchOnTarget(entity, deployTarget, deployLogPath, {
|
||||
skipClone: true,
|
||||
});
|
||||
} else if (!usesRemoteBuild) {
|
||||
if (compose.sourceType === "raw") {
|
||||
const rawCommand = getCreateComposeFileCommand(entity);
|
||||
await runWithLog(deployTarget, rawCommand, deployment.logPath);
|
||||
}
|
||||
await cloneAndPatchOnTarget(entity, deployTarget, deployment.logPath, {
|
||||
skipClone: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (freshVolumes && compose.composeType === "docker-compose") {
|
||||
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
|
||||
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, downWithLog);
|
||||
} else {
|
||||
await execAsync(downWithLog);
|
||||
}
|
||||
await runWithLog(deployTarget, downCommand, deployLogPath);
|
||||
}
|
||||
|
||||
command = "set -e;";
|
||||
command += await getBuildComposeCommand(compose);
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
const deployCommand = await getBuildComposeCommand(entity, {
|
||||
prebuilt: usesRemoteBuild,
|
||||
targetServerId: deployTarget,
|
||||
});
|
||||
await runWithLog(deployTarget, deployCommand, deployLogPath);
|
||||
|
||||
await appendDeployLogsIfSplit(
|
||||
deployTarget,
|
||||
deployLogPath,
|
||||
logTarget,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateCompose(composeId, {
|
||||
composeStatus: "done",
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await appendDeployLogsIfSplit(
|
||||
deployTarget,
|
||||
deployLogPath,
|
||||
logTarget,
|
||||
deployment.logPath,
|
||||
);
|
||||
} catch {
|
||||
// Best effort: deploy logs may not exist yet.
|
||||
}
|
||||
|
||||
let command = "";
|
||||
|
||||
// Only log details for non-ExecError errors
|
||||
if (!(error instanceof ExecError)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const encodedMessage = encodeBase64(message);
|
||||
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
|
||||
command += `echo "${encodedMessage}" | base64 -d >> ${quote([deployment.logPath])};`;
|
||||
}
|
||||
|
||||
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, command);
|
||||
command += `echo "\nError occurred ❌, check the logs for details." >> ${quote([deployment.logPath])};`;
|
||||
if (logTarget) {
|
||||
await execAsyncRemote(logTarget, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
|
||||
@ -293,20 +293,22 @@ export const createDeploymentCompose = async (
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
compose.buildServerId || compose.serverId,
|
||||
);
|
||||
try {
|
||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||
const logServerId = compose.buildServerId || compose.serverId;
|
||||
const { LOGS_PATH } = paths(!!logServerId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, compose.appName, fileName);
|
||||
|
||||
if (compose.serverId) {
|
||||
const server = await findServerById(compose.serverId);
|
||||
if (logServerId) {
|
||||
const server = await findServerById(logServerId);
|
||||
|
||||
const command = `
|
||||
mkdir -p ${LOGS_PATH}/${compose.appName};
|
||||
echo "Initializing deployment\n" >> ${logFilePath};
|
||||
echo "Initializing deployment" >> ${logFilePath};
|
||||
echo "Building on ${compose.buildServerId ? "Build Server" : logServerId ? "Deploy Server" : "Dokploy Server"}" >> ${logFilePath};
|
||||
`;
|
||||
|
||||
await execAsyncRemote(server.serverId, command);
|
||||
@ -326,6 +328,9 @@ echo "Initializing deployment\n" >> ${logFilePath};
|
||||
status: "running",
|
||||
logPath: logFilePath,
|
||||
startedAt: new Date().toISOString(),
|
||||
...(compose.buildServerId && {
|
||||
buildServerId: compose.buildServerId,
|
||||
}),
|
||||
})
|
||||
.returning();
|
||||
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
|
||||
@ -629,8 +634,9 @@ export const removeDeployment = async (deploymentId: string) => {
|
||||
const logPath = path.join(deployment.logPath);
|
||||
if (logPath && logPath !== ".") {
|
||||
const command = `rm -f ${logPath};`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
const logServerId = deployment.buildServerId || deployment.serverId;
|
||||
if (logServerId) {
|
||||
await execAsyncRemote(logServerId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
@ -781,10 +787,11 @@ export const removeDeploymentsByPreviewDeploymentId = async (
|
||||
|
||||
export const removeDeploymentsByComposeId = async (compose: Compose) => {
|
||||
const { appName } = compose;
|
||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||
const logServerId = compose.buildServerId || compose.serverId;
|
||||
const { LOGS_PATH } = paths(!!logServerId);
|
||||
const logsPath = path.join(LOGS_PATH, appName);
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, `rm -rf ${logsPath}`);
|
||||
if (logServerId) {
|
||||
await execAsyncRemote(logServerId, `rm -rf ${logsPath}`);
|
||||
} else {
|
||||
await removeDirectoryIfExistsContent(logsPath);
|
||||
}
|
||||
@ -847,6 +854,9 @@ const centralizedDeploymentsWith = {
|
||||
server: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
buildServer: {
|
||||
columns: { serverId: true, name: true, serverType: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
@ -103,7 +103,9 @@ export const haveActiveServices = async (serverId: string) => {
|
||||
columns: { serverId: true },
|
||||
with: {
|
||||
applications: { columns: { applicationId: true } },
|
||||
buildApplications: { columns: { applicationId: true } },
|
||||
compose: { columns: { composeId: true } },
|
||||
buildCompose: { columns: { composeId: true } },
|
||||
libsql: { columns: { libsqlId: true } },
|
||||
mariadb: { columns: { mariadbId: true } },
|
||||
mongo: { columns: { mongoId: true } },
|
||||
@ -119,7 +121,9 @@ export const haveActiveServices = async (serverId: string) => {
|
||||
|
||||
const total =
|
||||
currentServer?.applications?.length +
|
||||
currentServer?.buildApplications?.length +
|
||||
currentServer?.compose?.length +
|
||||
currentServer?.buildCompose?.length +
|
||||
currentServer?.libsql?.length +
|
||||
currentServer?.mariadb?.length +
|
||||
currentServer?.mongo?.length +
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import {
|
||||
findRegistryByIdWithCredentials,
|
||||
safeDockerLoginCommand,
|
||||
} from "@dokploy/server/services/registry";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import boxen from "boxen";
|
||||
import { quote } from "shell-quote";
|
||||
import { writeDomainsToCompose } from "../docker/domain";
|
||||
import {
|
||||
addDomainToCompose,
|
||||
hasBuildableServices,
|
||||
writeDomainsToCompose,
|
||||
} from "../docker/domain";
|
||||
import {
|
||||
encodeBase64,
|
||||
getEnvironmentVariablesObject,
|
||||
@ -14,31 +22,65 @@ import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type ComposeNested = InferResultType<
|
||||
"compose",
|
||||
{ environment: { with: { project: true } }; mounts: true; domains: true }
|
||||
{
|
||||
environment: { with: { project: true } };
|
||||
mounts: true;
|
||||
domains: true;
|
||||
buildRegistry: { columns: { password: false } };
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
type CreateCommandOptions = {
|
||||
prebuilt?: boolean;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
export const getBuildComposeCommand = async (
|
||||
rawCompose: ComposeNested,
|
||||
options?: { prebuilt?: boolean; targetServerId?: string | null },
|
||||
) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const targetServerId = options?.targetServerId ?? compose.serverId;
|
||||
const isRemote = !!targetServerId;
|
||||
const { COMPOSE_PATH } = paths(isRemote);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const command = createCommand(
|
||||
compose,
|
||||
mounts.length > 0 ? projectPath : undefined,
|
||||
);
|
||||
const prebuilt = options?.prebuilt ?? Boolean(compose.buildServerId);
|
||||
const command = createCommand(compose, {
|
||||
prebuilt,
|
||||
projectPath: mounts.length > 0 ? projectPath : undefined,
|
||||
});
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose)
|
||||
? getCreateEnvFileCommand(compose, targetServerId)
|
||||
: "";
|
||||
const exportEnvCommand = getExportEnvCommand(compose);
|
||||
|
||||
const newCompose = await writeDomainsToCompose(compose, domains);
|
||||
const buildRegistry =
|
||||
prebuilt && compose.buildRegistryId
|
||||
? await findRegistryByIdWithCredentials(compose.buildRegistryId)
|
||||
: undefined;
|
||||
|
||||
const registryLoginCommand = buildRegistry
|
||||
? `${safeDockerLoginCommand(
|
||||
buildRegistry.registryUrl,
|
||||
buildRegistry.username,
|
||||
buildRegistry.password,
|
||||
)} || { echo "❌ Registry login failed"; exit 1; }`
|
||||
: "";
|
||||
|
||||
const newCompose = await writeDomainsToCompose(compose, domains, {
|
||||
buildRegistry,
|
||||
loadServerId: targetServerId,
|
||||
});
|
||||
|
||||
const logContent = `
|
||||
App Name: ${appName}
|
||||
Build Compose 🐳
|
||||
Detected: ${mounts.length} mounts 📂
|
||||
Command: docker ${command}
|
||||
Source Type: docker ${sourceType} ✅
|
||||
Compose Type: ${composeType} ✅`;
|
||||
Compose Type: ${composeType} ✅
|
||||
Remote Build: ${prebuilt ? "enabled (pre-built images)" : "disabled"}`;
|
||||
|
||||
const logBox = boxen(logContent, {
|
||||
padding: {
|
||||
@ -61,6 +103,8 @@ Compose Type: ${composeType} ✅`;
|
||||
|
||||
cd "${projectPath}";
|
||||
|
||||
${registryLoginCommand}
|
||||
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
|
||||
env -i PATH="$PATH" HOME="$HOME" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
||||
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||
@ -75,6 +119,82 @@ Compose Type: ${composeType} ✅`;
|
||||
return bashCommand;
|
||||
};
|
||||
|
||||
export const getComposeRemoteBuildCommand = async (
|
||||
rawCompose: ComposeNested,
|
||||
buildServerId: string,
|
||||
) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
if (!compose.buildRegistryId) {
|
||||
throw new Error("Build registry is required for remote compose builds");
|
||||
}
|
||||
|
||||
const buildRegistry = await findRegistryByIdWithCredentials(
|
||||
compose.buildRegistryId,
|
||||
);
|
||||
const { COMPOSE_PATH } = paths(true);
|
||||
const { sourceType, appName, composeType } = compose;
|
||||
const path =
|
||||
sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
|
||||
const loadCompose = { ...compose, serverId: buildServerId };
|
||||
const composeSpec = await addDomainToCompose(loadCompose, []);
|
||||
|
||||
if (!composeSpec || !hasBuildableServices(composeSpec)) {
|
||||
return `
|
||||
echo "ℹ️ No services with build: defined — skipping remote build phase";
|
||||
`;
|
||||
}
|
||||
|
||||
const composeWrite = await writeDomainsToCompose(compose, [], {
|
||||
buildRegistry,
|
||||
loadServerId: buildServerId,
|
||||
});
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose, buildServerId)
|
||||
: "";
|
||||
const loginCommand = `${safeDockerLoginCommand(
|
||||
buildRegistry.registryUrl,
|
||||
buildRegistry.username,
|
||||
buildRegistry.password,
|
||||
)} || { echo "❌ Registry login failed"; exit 1; }`;
|
||||
|
||||
const logContent = `
|
||||
App Name: ${appName}
|
||||
Remote Build Compose 🐳
|
||||
Build Server: ${buildServerId}
|
||||
Compose Type: ${composeType} ✅`;
|
||||
|
||||
const logBox = boxen(logContent, {
|
||||
padding: { left: 1, right: 1, bottom: 1 },
|
||||
width: 80,
|
||||
borderStyle: "double",
|
||||
});
|
||||
|
||||
return `
|
||||
set -e
|
||||
{
|
||||
echo "${logBox}";
|
||||
|
||||
${composeWrite}
|
||||
|
||||
${envCommand}
|
||||
|
||||
cd "${projectPath}";
|
||||
|
||||
${loginCommand}
|
||||
|
||||
env -i PATH="$PATH" HOME="$HOME" docker compose -p ${quote([appName])} -f ${quote([path])} build 2>&1 || { echo "Error: ❌ Docker compose build failed"; exit 1; }
|
||||
env -i PATH="$PATH" HOME="$HOME" docker compose -p ${quote([appName])} -f ${quote([path])} push 2>&1 || { echo "Error: ❌ Docker compose push failed"; exit 1; }
|
||||
|
||||
echo "Remote Compose Build & Push: ✅";
|
||||
} || {
|
||||
echo "Error: ❌ Remote build script execution failed";
|
||||
exit 1
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
// Shell control characters that must never appear in a user-provided compose
|
||||
// command: they would let it break out of the `docker ${command}` invocation
|
||||
// into arbitrary host commands. A normal docker compose CLI line never needs them.
|
||||
@ -122,7 +242,14 @@ const sanitizeCommand = (command: string) => {
|
||||
return restCommand.join(" ");
|
||||
};
|
||||
|
||||
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
export const createCommand = (
|
||||
compose: ComposeNested,
|
||||
options?: CreateCommandOptions | string,
|
||||
) => {
|
||||
const { prebuilt, projectPath } =
|
||||
typeof options === "string"
|
||||
? { projectPath: options }
|
||||
: (options ?? {});
|
||||
const { composeType, appName, sourceType } = compose;
|
||||
if (compose.command) {
|
||||
return `${sanitizeCommand(compose.command)}`;
|
||||
@ -139,8 +266,12 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
const envFileFlag = compose.createEnvFile
|
||||
? `--env-file ${quote([join(dirname(compose.composePath || "docker-compose.yml"), ".env")])} `
|
||||
: "";
|
||||
const pullFlag = compose.pullImages ? " --pull always" : "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
|
||||
if (prebuilt) {
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} pull && docker compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --no-build --remove-orphans`;
|
||||
} else {
|
||||
const pullFlag = compose.pullImages ? " --pull always" : "";
|
||||
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}${envFileFlag}-f ${quote([path])} up -d --build --remove-orphans${pullFlag}`;
|
||||
}
|
||||
} else if (composeType === "stack") {
|
||||
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
|
||||
}
|
||||
@ -148,8 +279,11 @@ export const createCommand = (compose: ComposeNested, projectPath?: string) => {
|
||||
return command;
|
||||
};
|
||||
|
||||
export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
export const getCreateEnvFileCommand = (
|
||||
compose: ComposeNested,
|
||||
targetServerId?: string | null,
|
||||
) => {
|
||||
const { COMPOSE_PATH } = paths(!!(targetServerId ?? compose.serverId));
|
||||
const { env, composePath, appName } = compose;
|
||||
const composeFilePath =
|
||||
join(COMPOSE_PATH, appName, "code", composePath) ||
|
||||
|
||||
@ -4,11 +4,13 @@ import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { network, patch } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Registry } from "@dokploy/server/services/registry";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { getRegistryTag } from "../cluster/upload";
|
||||
import { cloneBitbucketRepository } from "../providers/bitbucket";
|
||||
import { cloneGitRepository } from "../providers/git";
|
||||
import { cloneGiteaRepository } from "../providers/gitea";
|
||||
@ -110,12 +112,48 @@ export const readComposeFile = async (compose: Compose) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const sanitizeComposeServiceName = (serviceName: string): string =>
|
||||
serviceName.toLowerCase().replace(/[^a-z0-9._-]/g, "-");
|
||||
|
||||
export const applyBuildRegistryImages = (
|
||||
spec: ComposeSpecification,
|
||||
registry: Registry,
|
||||
appName: string,
|
||||
): ComposeSpecification => {
|
||||
const result = structuredClone(spec);
|
||||
if (!result.services) return result;
|
||||
|
||||
for (const [serviceName, service] of Object.entries(result.services)) {
|
||||
if (service?.build) {
|
||||
const localImage = `${sanitizeComposeServiceName(appName)}-${sanitizeComposeServiceName(serviceName)}:latest`;
|
||||
service.image = getRegistryTag(registry, localImage);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const hasBuildableServices = (
|
||||
spec: ComposeSpecification | null,
|
||||
): boolean => {
|
||||
if (!spec?.services) return false;
|
||||
return Object.values(spec.services).some((service) => Boolean(service?.build));
|
||||
};
|
||||
|
||||
export const writeDomainsToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
options?: {
|
||||
buildRegistry?: Registry;
|
||||
loadServerId?: string | null;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const composeConverted = await addDomainToCompose(compose, domains);
|
||||
const loadCompose =
|
||||
options?.loadServerId !== undefined
|
||||
? { ...compose, serverId: options.loadServerId }
|
||||
: compose;
|
||||
let composeConverted = await addDomainToCompose(loadCompose, domains);
|
||||
const path = getComposePath(compose);
|
||||
|
||||
if (!composeConverted) {
|
||||
@ -125,6 +163,14 @@ exit 1;
|
||||
`;
|
||||
}
|
||||
|
||||
if (options?.buildRegistry) {
|
||||
composeConverted = applyBuildRegistryImages(
|
||||
composeConverted,
|
||||
options.buildRegistry,
|
||||
compose.appName,
|
||||
);
|
||||
}
|
||||
|
||||
const composeString = stringify(composeConverted, { lineWidth: 1000 });
|
||||
const encodedContent = encodeBase64(composeString);
|
||||
return `echo "${encodedContent}" | base64 -d > "${path}";`;
|
||||
|
||||
@ -4,6 +4,25 @@ packages:
|
||||
- "apps/schedules"
|
||||
- "packages/server"
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- "@prisma/client"
|
||||
- "@prisma/engines"
|
||||
- "@scarf/scarf"
|
||||
- "@tree-sitter-grammars/tree-sitter-yaml"
|
||||
- bcrypt
|
||||
- better-sqlite3
|
||||
- core-js-pure
|
||||
- cpu-features
|
||||
- esbuild
|
||||
- msgpackr-extract
|
||||
- node-pty
|
||||
- prisma
|
||||
- protobufjs
|
||||
- sharp
|
||||
- ssh2
|
||||
- tree-sitter
|
||||
- tree-sitter-json
|
||||
|
||||
# Supply-chain hardening: refuse package versions younger than N days,
|
||||
# so newly-published malicious versions get caught/yanked before we install them.
|
||||
# Disabled for now — kept breaking builds on unrelated fresh transitive deps
|
||||
|
||||
Loading…
Reference in New Issue
Block a user