Files
dokploy/packages/server/src/setup/vector-setup.ts
T

786 lines
22 KiB
TypeScript

import { IS_CLOUD, paths } from "@dokploy/server/constants";
import {
findEnabledTelemetryProvidersByOrganization,
findTelemetryProvidersByOrganization,
type TelemetryProvider,
toRuntimeConfig,
} from "@dokploy/server/services/logs-and-metrics/service";
import {
findServerById,
findServersByOrganizationForVectorAgent,
getAccessibleServerIds,
} from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { encodeBase64 } from "@dokploy/server/utils/docker/utils";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
import type Dockerode from "dockerode";
import type { ContainerSpec, CreateServiceOptions } from "dockerode";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { stringify } from "yaml";
import { getTelemetryProviderAdapter } from "../services/logs-and-metrics/providers/registry";
import {
type TelemetrySignal,
type VectorSinkConfig,
type VectorTransformConfig,
vrlString,
} from "../services/logs-and-metrics/types";
export const VECTOR_IMAGE = "timberio/vector:0.58.0-alpine";
const VECTOR_SERVICE_NAME = "dokploy-vector";
const VECTOR_CONFIG_CONTAINER_DIR = "/etc/vector";
const VECTOR_CONFIG_CONTAINER_PATH = `${VECTOR_CONFIG_CONTAINER_DIR}/vector.yaml`;
const VECTOR_DATA_DIR_CONTAINER = "/var/lib/vector";
export const CADVISOR_IMAGE = "ghcr.io/google/cadvisor:v0.60.6";
export const CADVISOR_SERVICE_NAME = "dokploy-cadvisor";
export const CADVISOR_PORT = 4510;
export const METRICS_SCRAPE_INTERVAL_SECS = 30;
export const LOCAL_SERVER_NAME = "Dokploy Server (local)";
const DOKPLOY_LABELS = [
"dokploy.organization.id",
"dokploy.project",
"dokploy.project.id",
"dokploy.environment",
"dokploy.environment.id",
"dokploy.application",
"dokploy.application.id",
"dokploy.service",
];
// Pseudo filesystems report 0 bytes, so their used_ratio is NaN, which InfluxDB and CloudWatch reject.
const FILESYSTEM_EXCLUDES = [
"overlay",
"tmpfs",
"squashfs",
"devtmpfs",
"nsfs",
"proc",
"sysfs",
"devpts",
"cgroup",
"cgroup2",
"mqueue",
"hugetlbfs",
"binfmt_misc",
"debugfs",
"tracefs",
"securityfs",
"pstore",
"bpf",
"configfs",
"fusectl",
"autofs",
"efivarfs",
"selinuxfs",
"rpc_pipefs",
"ramfs",
"rootfs",
"fuse.lxcfs",
];
const METRIC_TAGS: Array<[label: string, tag: string]> = [
["container_label_dokploy_organization_id", "dokploy_organization"],
["container_label_dokploy_project", "dokploy_project"],
["container_label_dokploy_project_id", "dokploy_project_id"],
["container_label_dokploy_environment", "dokploy_environment"],
["container_label_dokploy_environment_id", "dokploy_environment_id"],
["container_label_dokploy_application", "dokploy_application"],
["container_label_dokploy_application_id", "dokploy_application_id"],
["container_label_dokploy_service", "dokploy_service"],
];
const buildScopeTransformSource = (serverName: string): string =>
[
`.dokploy_server = ${vrlString(serverName)}`,
'.dokploy_organization = .label."dokploy.organization.id" || ""',
'.dokploy_project = .label."dokploy.project" || ""',
'.dokploy_project_id = .label."dokploy.project.id" || ""',
'.dokploy_environment = .label."dokploy.environment" || ""',
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
'.dokploy_application = .label."dokploy.application" || ""',
'.dokploy_application_id = .label."dokploy.application.id" || ""',
'.dokploy_service = .label."dokploy.service" || ""',
].join("\n");
// cAdvisor also exposes go_*/process_*/machine_* series and sets every whitelisted label, empty, on all containers.
export const buildMetricsScopeTransformSource = (serverName: string): string =>
[
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
`.tags.dokploy_server = ${vrlString(serverName)}`,
...METRIC_TAGS.map(
([label, tag]) =>
`value = del(.tags.${label})\nif is_string(value) && value != "" { .tags.${tag} = value }`,
),
"del(.tags.id)",
].join("\n");
const buildSinksAndTransforms = (
providers: Array<TelemetryProvider>,
baseTransformId: string,
signal: TelemetrySignal,
): {
sinks: Record<string, VectorSinkConfig>;
transforms: Record<string, VectorTransformConfig>;
} => {
const sinks: Record<string, VectorSinkConfig> = {};
const transforms: Record<string, VectorTransformConfig> = {};
const suffix = signal === "metrics" ? "_metrics" : "";
for (const provider of providers) {
try {
const adapter = getTelemetryProviderAdapter(provider.providerType);
const runtimeConfig = toRuntimeConfig(provider);
const sinkId = `sink_${provider.telemetryProviderId}${suffix}`;
const transformId = `transform_${provider.telemetryProviderId}${suffix}`;
const transform = adapter.toVectorTransform?.(
runtimeConfig,
transformId,
baseTransformId,
signal,
);
const inputId = transform ? transformId : baseTransformId;
const sink = adapter.toVectorSink(runtimeConfig, sinkId, inputId, signal);
if (transform) {
transforms[transformId] = transform;
}
sinks[sinkId] = sink;
} catch (error) {
console.error(
`[Vector] Skipping provider "${provider.name}" (${provider.telemetryProviderId}) — invalid config:`,
error,
);
}
}
return { sinks, transforms };
};
export const buildVectorConfigYaml = ({
logProviders,
metricsProviders,
organizationId,
serverName,
}: {
logProviders: Array<TelemetryProvider>;
metricsProviders: Array<TelemetryProvider>;
organizationId?: string;
serverName: string;
}) => {
const logsBaseId = organizationId
? "dokploy_scope_local_only"
: "dokploy_scope";
const metricsBaseId = organizationId
? "dokploy_metrics_scope_local_only"
: "dokploy_metrics_scope";
const logs = buildSinksAndTransforms(logProviders, logsBaseId, "logs");
const metrics = buildSinksAndTransforms(
metricsProviders,
metricsBaseId,
"metrics",
);
const config = {
data_dir: VECTOR_DATA_DIR_CONTAINER,
sources: {
...(logProviders.length > 0
? {
docker_logs_source: {
type: "docker_logs",
docker_host: "unix:///var/run/docker.sock",
},
}
: {}),
...(metricsProviders.length > 0
? {
host_metrics_source: {
type: "host_metrics",
scrape_interval_secs: METRICS_SCRAPE_INTERVAL_SECS,
collectors: [
"cpu",
"memory",
"disk",
"filesystem",
"network",
"load",
"host",
],
filesystem: {
filesystems: { excludes: FILESYSTEM_EXCLUDES },
},
network: {
devices: { excludes: ["veth*", "br-*", "docker*"] },
},
},
cadvisor_source: {
type: "prometheus_scrape",
endpoints: [`http://127.0.0.1:${CADVISOR_PORT}/metrics`],
scrape_interval_secs: METRICS_SCRAPE_INTERVAL_SECS,
scrape_timeout_secs: 10,
},
}
: {}),
},
transforms: {
...(logProviders.length > 0
? {
dokploy_scope: {
type: "remap",
inputs: ["docker_logs_source"],
source: buildScopeTransformSource(serverName),
},
...(organizationId
? {
dokploy_scope_local_only: {
type: "filter",
inputs: ["dokploy_scope"],
condition: `.dokploy_organization == ${vrlString(organizationId)}`,
},
}
: {}),
}
: {}),
...(metricsProviders.length > 0
? {
dokploy_metrics_scope: {
type: "remap",
inputs: ["host_metrics_source", "cadvisor_source"],
drop_on_abort: true,
source: buildMetricsScopeTransformSource(serverName),
},
...(organizationId
? {
dokploy_metrics_scope_local_only: {
type: "filter",
inputs: ["dokploy_metrics_scope"],
condition: `.namespace == "host" || .tags.dokploy_organization == ${vrlString(organizationId)}`,
},
}
: {}),
}
: {}),
...logs.transforms,
...metrics.transforms,
},
sinks: { ...logs.sinks, ...metrics.sinks },
};
// Vector 0.58 only expands $VAR with --dangerously-allow-env-var-interpolation, so a $ stays literal.
return stringify(config);
};
export const collectSecretValues = (
providers: Array<TelemetryProvider>,
): string[] => {
const values: string[] = [];
for (const provider of providers) {
if (provider.endpoint) values.push(provider.endpoint);
if (provider.apiKey) values.push(provider.apiKey);
if (provider.apiSecret) values.push(provider.apiSecret);
const secretKeys = getTelemetryProviderAdapter(provider.providerType)
.credentialFields.filter((field) => field.type === "password")
.map((field) => field.key);
for (const key of secretKeys) {
const value = provider.extraConfig?.[key];
if (typeof value === "string" && value.length > 0) values.push(value);
}
}
return values.filter((v) => v.length > 0).sort((a, b) => b.length - a.length);
};
export const redactSecrets = (text: string, secrets: string[]): string => {
let redacted = text;
for (const secret of secrets) {
redacted = redacted.split(secret).join("[redacted]");
}
return redacted;
};
const runOnTarget = async (serverId: string | undefined, command: string) => {
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
};
const describeError = (error: unknown) =>
error instanceof ExecError
? error.stderr || error.stdout || error.message
: error instanceof Error
? error.message
: String(error);
const syncVectorConfig = async (
serverId: string | undefined,
yamlStr: string,
secrets: string[],
) => {
const { VECTOR_PATH } = paths(!!serverId);
const configPath = `${VECTOR_PATH}/vector.yaml`;
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate.${nanoid(10)}`;
const encodedYaml = encodeBase64(yamlStr);
// Failed commands echo their command line, which carries the whole config.
const redact = (error: unknown) =>
redactSecrets(describeError(error), [encodedYaml, ...secrets]);
try {
await runOnTarget(
serverId,
`umask 077 && mkdir -p ${quote([`${VECTOR_PATH}/data`])} && chmod 700 ${quote([VECTOR_PATH])} ${quote([`${VECTOR_PATH}/data`])} && echo "${encodedYaml}" | base64 -d > ${quote([candidatePath])}`,
);
} catch (error) {
throw new Error(`Failed to write the Vector config: ${redact(error)}`);
}
try {
await runOnTarget(
serverId,
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${quote([`${candidatePath}:/etc/vector/vector.yaml:ro`])} ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
);
} catch (error) {
const sanitizedDetail = redact(error);
try {
await runOnTarget(serverId, `rm -f ${quote([candidatePath])}`);
} catch (cleanupError) {
console.error(
`[Vector] Failed to remove invalid config candidate ${candidatePath}${
serverId ? ` on server ${serverId}` : ""
}:`,
cleanupError,
);
}
throw new Error(
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
);
}
try {
await runOnTarget(
serverId,
`mv ${quote([candidatePath])} ${quote([configPath])}`,
);
} catch (error) {
throw new Error(`Failed to apply the Vector config: ${redact(error)}`);
}
};
const deploySwarmService = async (
docker: Dockerode,
settings: CreateServiceOptions,
label: string,
) => {
const service = docker.getService(settings.Name as string);
let inspect: Awaited<ReturnType<typeof service.inspect>> | undefined;
try {
inspect = await service.inspect();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
if (!inspect) {
try {
await docker.createService(settings);
console.log(`${label} Started ✅`);
} catch (error: any) {
if (error?.statusCode !== 409) {
throw error;
}
console.log(`${label} service already exists, continuing...`);
}
return;
}
// --watch-config reloads a changed file; only a changed container spec needs a restart.
// Docker rewrites mount sources and drops defaults on inspect, so compare what we set.
const containerSpecKey = (template: unknown) => {
const spec = (template as { ContainerSpec?: ContainerSpec })?.ContainerSpec;
return JSON.stringify({
image: spec?.Image,
args: spec?.Args,
env: spec?.Env,
mounts: spec?.Mounts?.map(
(mount) => `${mount.Target}:${mount.ReadOnly ? "ro" : "rw"}`,
),
});
};
const specChanged =
containerSpecKey(inspect.Spec.TaskTemplate) !==
containerSpecKey(settings.TaskTemplate);
await service.update({
version: Number.parseInt(inspect.Version.Index, 10),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate:
(inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + (specChanged ? 1 : 0),
},
});
console.log(`${label} Updated ✅`);
};
const removeSwarmService = async (docker: Dockerode, name: string) => {
try {
await docker.getService(name).remove();
} catch (error: any) {
if (error?.statusCode !== 404) {
throw error;
}
}
};
// --watch-config misses a replaced file on some mounts, and SIGHUP makes Vector reload it anyway.
const reloadVectorConfig = async (docker: Dockerode) => {
const containers = await docker.listContainers({
filters: JSON.stringify({
label: [`com.docker.swarm.service.name=${VECTOR_SERVICE_NAME}`],
}),
});
for (const container of containers) {
try {
await docker.getContainer(container.Id).kill({ signal: "SIGHUP" });
} catch (error: any) {
if (error?.statusCode !== 404 && error?.statusCode !== 409) {
throw error;
}
}
}
};
const deployVectorService = async (serverId?: string) => {
const { VECTOR_PATH } = paths(!!serverId);
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: VECTOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: VECTOR_IMAGE,
Args: ["--config", VECTOR_CONFIG_CONTAINER_PATH, "--watch-config"],
Env: ["PROCFS_ROOT=/host/proc", "SYSFS_ROOT=/host/sys"],
Mounts: [
{
Type: "bind",
Source: "/var/run/docker.sock",
Target: "/var/run/docker.sock",
ReadOnly: true,
},
{
Type: "bind",
Source: VECTOR_PATH,
Target: VECTOR_CONFIG_CONTAINER_DIR,
ReadOnly: true,
},
{
Type: "bind",
Source: `${VECTOR_PATH}/data`,
Target: VECTOR_DATA_DIR_CONTAINER,
ReadOnly: false,
},
{
Type: "bind",
Source: "/proc",
Target: "/host/proc",
ReadOnly: true,
},
{
Type: "bind",
Source: "/sys",
Target: "/host/sys",
ReadOnly: true,
},
],
},
Networks: [{ Target: "host" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
await deploySwarmService(docker, settings, "Vector");
await reloadVectorConfig(docker);
};
export const deployCadvisorService = async (serverId?: string) => {
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: CADVISOR_SERVICE_NAME,
TaskTemplate: {
ContainerSpec: {
Image: CADVISOR_IMAGE,
Args: [
"--listen_ip=127.0.0.1",
`--port=${CADVISOR_PORT}`,
"--docker_only=true",
"--store_container_labels=false",
`--whitelisted_container_labels=${DOKPLOY_LABELS.join(",")}`,
`--housekeeping_interval=${METRICS_SCRAPE_INTERVAL_SECS}s`,
"--storage_duration=1m0s",
"--event_storage_event_limit=default=0",
"--event_storage_age_limit=default=0",
"--disable_metrics=advtcp,cpu_topology,cpuset,hugetlb,memory_numa,oom_event,percpu,perf_event,process,referenced_memory,resctrl,sched,tcp,udp",
],
// The image healthcheck probes the default port and would make Swarm restart the task.
HealthCheck: { Test: ["NONE"] },
Mounts: [
{ Type: "bind", Source: "/", Target: "/rootfs", ReadOnly: true },
// /run holds both docker.sock and containerd.sock; /var/run is a symlink to it.
{ Type: "bind", Source: "/run", Target: "/run", ReadOnly: true },
{ Type: "bind", Source: "/sys", Target: "/sys", ReadOnly: true },
{
Type: "bind",
Source: "/var/lib/docker",
Target: "/var/lib/docker",
ReadOnly: true,
},
],
},
Networks: [{ Target: "host" }],
Placement: {
Constraints: ["node.role==manager"],
},
},
Mode: {
Replicated: {
Replicas: 1,
},
},
};
await deploySwarmService(docker, settings, "cAdvisor");
};
export const removeCadvisorService = async (serverId?: string) => {
const docker = await getRemoteDocker(serverId);
await removeSwarmService(docker, CADVISOR_SERVICE_NAME);
};
const vectorTargetLocks = new Map<string, Promise<unknown>>();
// In-process only: Dokploy runs a single dashboard process per host.
export const withVectorTargetLock = async <T>(
serverId: string | undefined,
fn: () => Promise<T>,
): Promise<T> => {
const key = serverId ?? "local";
const previous = vectorTargetLocks.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(fn);
vectorTargetLocks.set(key, current);
try {
return await current;
} finally {
if (vectorTargetLocks.get(key) === current) {
vectorTargetLocks.delete(key);
}
}
};
export const getVectorTargetState = async (serverId?: string) => {
if (serverId) {
const server = await findServerById(serverId);
return {
organizationId: server.organizationId,
serverName: server.name,
telemetryProviderIds: server.telemetryProviderIds,
};
}
const settings = await getWebServerSettings();
return {
organizationId: settings?.vectorAgentOrganizationId ?? null,
serverName: LOCAL_SERVER_NAME,
telemetryProviderIds: settings?.telemetryProviderIds ?? [],
};
};
// Always called inside withVectorTargetLock: makes the host match the saved selection.
export const reconcileVectorAgent = async (serverId?: string) => {
const state = await getVectorTargetState(serverId);
const providers = (
state.organizationId
? await findEnabledTelemetryProvidersByOrganization(state.organizationId)
: []
).filter((p) => state.telemetryProviderIds.includes(p.telemetryProviderId));
const logProviders = providers.filter((p) => p.signals.includes("logs"));
const metricsProviders = providers.filter((p) =>
p.signals.includes("metrics"),
);
if (logProviders.length === 0 && metricsProviders.length === 0) {
await removeVectorAgent(serverId);
return;
}
const yamlStr = buildVectorConfigYaml({
logProviders,
metricsProviders,
organizationId: serverId ? undefined : (state.organizationId ?? undefined),
serverName: state.serverName,
});
await syncVectorConfig(serverId, yamlStr, collectSecretValues(providers));
await deployVectorService(serverId);
if (metricsProviders.length > 0) {
await deployCadvisorService(serverId);
} else {
await removeCadvisorService(serverId);
}
};
export const removeVectorAgent = async (serverId?: string) => {
if (serverId) {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`Server ${serverId} has no SSH key`);
}
}
const docker = await getRemoteDocker(serverId);
try {
await removeSwarmService(docker, CADVISOR_SERVICE_NAME);
} catch (error) {
console.error(
`[Vector] Failed to remove cAdvisor${serverId ? ` on server ${serverId}` : ""}:`,
error,
);
}
await removeSwarmService(docker, VECTOR_SERVICE_NAME);
const { VECTOR_PATH } = paths(!!serverId);
await runOnTarget(serverId, `rm -rf ${quote([VECTOR_PATH])}`);
};
type VectorStatus = "running" | "not-running" | "stopped" | "unknown";
const serviceState = async (
docker: Dockerode,
name: string,
): Promise<VectorStatus> => {
// listTasks returns [] instead of 404 for a missing service, so inspect first.
try {
await docker.getService(name).inspect();
} catch (error: any) {
if (error?.statusCode === 404) {
return "stopped";
}
throw error;
}
const tasks = await docker.listTasks({
filters: JSON.stringify({
service: [name],
"desired-state": ["running"],
}),
});
return tasks.some((task) => task.Status?.State === "running")
? "running"
: "not-running";
};
const inspectVectorStatus = async (
serverId: string | undefined,
requireCadvisor: boolean,
): Promise<VectorStatus> => {
const docker = await getRemoteDocker(serverId);
const vector = await serviceState(docker, VECTOR_SERVICE_NAME);
if (vector !== "running" || !requireCadvisor) {
return vector;
}
const cadvisor = await serviceState(docker, CADVISOR_SERVICE_NAME);
return cadvisor === "running" ? "running" : "not-running";
};
const VECTOR_CHECK_TIMEOUT_MS = 5000;
const vectorStatus = async (
serverId: string | null,
requireCadvisor: boolean,
): Promise<VectorStatus> => {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
inspectVectorStatus(serverId ?? undefined, requireCadvisor),
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("timeout")),
VECTOR_CHECK_TIMEOUT_MS,
);
}),
]);
} catch {
return "unknown";
} finally {
clearTimeout(timer);
}
};
export const getVectorAgentTargets = async (session: {
userId: string;
activeOrganizationId: string;
}) => {
const organizationId = session.activeOrganizationId;
const [allServers, settings, accessibleIds, providers] = await Promise.all([
findServersByOrganizationForVectorAgent(organizationId),
IS_CLOUD ? Promise.resolve(null) : getWebServerSettings(),
getAccessibleServerIds(session),
findTelemetryProvidersByOrganization(organizationId),
]);
const metricsIds = new Set(
providers
.filter((p) => p.enabled && p.signals.includes("metrics"))
.map((p) => p.telemetryProviderId),
);
const servers = allServers.filter((s) => accessibleIds.has(s.serverId));
const webOwnsThisOrg =
!!settings &&
(settings.vectorAgentOrganizationId === null ||
settings.vectorAgentOrganizationId === organizationId);
// Deleting the owner sets the column to null and leaves its ids behind.
const webIsOwned = !!settings?.vectorAgentOrganizationId;
const targets: Array<{
serverId: string | null;
name: string;
ipAddress: string | null;
telemetryProviderIds: string[];
}> = [
...(settings && webOwnsThisOrg
? [
{
serverId: null,
name: LOCAL_SERVER_NAME,
ipAddress: null,
telemetryProviderIds: webIsOwned
? settings.telemetryProviderIds
: [],
},
]
: []),
...servers.map((s) => ({
serverId: s.serverId,
name: s.name,
ipAddress: s.ipAddress,
telemetryProviderIds: s.telemetryProviderIds,
})),
];
return Promise.all(
targets.map(async (target) => ({
...target,
status: await vectorStatus(
target.serverId,
target.telemetryProviderIds.some((id) => metricsIds.has(id)),
),
})),
);
};