mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
refactor: clean up log-management code and rebuild migrations
This commit is contained in:
parent
88118cdb3f
commit
d803fe6123
@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
||||
- **Log Management**: Ship container logs to Grafana Loki, Datadog, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch Logs, tagged by project/environment/application.
|
||||
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
||||
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||
|
||||
@ -0,0 +1,356 @@
|
||||
import {
|
||||
getLogProviderAdapter,
|
||||
logProviderAdapters,
|
||||
} from "@dokploy/server/services/log-management/providers/registry";
|
||||
import type { LogProviderRuntimeConfig } from "@dokploy/server/services/log-management/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseConfig: LogProviderRuntimeConfig = {
|
||||
logProviderId: "log-provider-1",
|
||||
name: "test",
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
describe("getLogProviderAdapter", () => {
|
||||
it("returns the registered adapter for each known type", () => {
|
||||
expect(getLogProviderAdapter("loki").type).toBe("loki");
|
||||
expect(getLogProviderAdapter("datadog").type).toBe("datadog");
|
||||
expect(getLogProviderAdapter("betterstack").type).toBe("betterstack");
|
||||
expect(getLogProviderAdapter("elasticsearch").type).toBe("elasticsearch");
|
||||
expect(getLogProviderAdapter("splunk_hec").type).toBe("splunk_hec");
|
||||
expect(getLogProviderAdapter("aws_cloudwatch").type).toBe("aws_cloudwatch");
|
||||
});
|
||||
|
||||
it("throws an explicit error for an unregistered type", () => {
|
||||
// @ts-expect-error: intentionally an unregistered type
|
||||
expect(() => getLogProviderAdapter("unknown")).toThrow(
|
||||
/No LogProviderAdapter registered/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lokiAdapter.toVectorSink", () => {
|
||||
it("maps endpoint + labels, without tenant_id if not set", () => {
|
||||
const adapter = logProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.type).toBe("loki");
|
||||
expect(sink.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(sink.endpoint).toBe("https://loki.example.com");
|
||||
expect(sink.labels).toMatchObject({
|
||||
dokploy_project: "{{ dokploy_project }}",
|
||||
dokploy_application: "{{ dokploy_application }}",
|
||||
dokploy_organization: "{{ dokploy_organization }}",
|
||||
});
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
expect(sink.buffer).toEqual({
|
||||
type: "disk",
|
||||
max_size: 268_435_488,
|
||||
when_full: "block",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes tenant_id when present in extraConfig", () => {
|
||||
const adapter = logProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://loki.example.com",
|
||||
extraConfig: { tenantId: "tenant-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.tenant_id).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
||||
expect(logProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter.toVectorSink", () => {
|
||||
it("defaults site to datadoghq.com, no `tags` field (datadog_logs no lo soporta)", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, apiKey: "dd-key" },
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
);
|
||||
expect(sink.type).toBe("datadog_logs");
|
||||
expect(sink.default_api_key).toBe("dd-key");
|
||||
expect(sink.site).toBe("datadoghq.com");
|
||||
expect(sink.tags).toBeUndefined();
|
||||
expect(sink.inputs).toEqual(["transform_2"]);
|
||||
});
|
||||
|
||||
it("uses extraConfig.site when present", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "https://datadoghq.eu/" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("has toVectorTransform to build .ddtags from the scoping fields — confirmado que datadog_logs no tiene un campo `tags` de config (vector generate-schema)", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain(".ddtags");
|
||||
});
|
||||
|
||||
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
|
||||
const adapter = logProviderAdapters.datadog;
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(transform?.source).toContain(
|
||||
'replace(to_string!(.dokploy_project), ",", "_")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter", () => {
|
||||
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
||||
const adapter = logProviderAdapters.betterstack;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(transform).toEqual({
|
||||
type: "remap",
|
||||
inputs: ["dokploy_scope"],
|
||||
source: ".dt = del(.timestamp)",
|
||||
});
|
||||
});
|
||||
|
||||
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
|
||||
const adapter = logProviderAdapters.betterstack;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "source-token",
|
||||
},
|
||||
"sink_3",
|
||||
"transform_1",
|
||||
);
|
||||
expect(sink.type).toBe("http");
|
||||
expect(sink.inputs).toEqual(["transform_1"]);
|
||||
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
|
||||
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
|
||||
expect(sink.compression).toBe("gzip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchAdapter", () => {
|
||||
it("has a toVectorTransform that flattens .label to a JSON string", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_4",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain("encode_json(.label)");
|
||||
});
|
||||
|
||||
it("uses basic auth when a username is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.type).toBe("elasticsearch");
|
||||
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
|
||||
expect(sink.auth).toEqual({
|
||||
strategy: "basic",
|
||||
user: "elastic",
|
||||
password: "secret",
|
||||
});
|
||||
expect(sink.request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to an ApiKey header when no username is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "key-123",
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.request).toEqual({
|
||||
headers: { Authorization: "ApiKey key-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
expect(() =>
|
||||
adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
),
|
||||
).toThrow(/Password.*required/i);
|
||||
});
|
||||
|
||||
it("includes bulk.index only when extraConfig.index is set", () => {
|
||||
const adapter = logProviderAdapters.elasticsearch;
|
||||
const withoutIndex = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(withoutIndex.bulk).toBeUndefined();
|
||||
const withIndex = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { index: "dokploy-%Y.%m.%d" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(logProviderAdapters.splunk_hec.toVectorTransform).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
|
||||
const adapter = logProviderAdapters.splunk_hec;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec-token",
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.type).toBe("splunk_hec_logs");
|
||||
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
|
||||
expect(sink.default_token).toBe("hec-token");
|
||||
expect(sink.encoding).toEqual({ codec: "json" });
|
||||
expect(sink.index).toBeUndefined();
|
||||
expect(sink.sourcetype).toBeUndefined();
|
||||
|
||||
const withExtras = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec-token",
|
||||
extraConfig: { index: "dokploy", sourcetype: "docker" },
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(withExtras.index).toBe("dokploy");
|
||||
expect(withExtras.sourcetype).toBe("docker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
logProviderAdapters.aws_cloudwatch.toVectorTransform,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toVectorSink maps access key/secret, region and log group/stream", () => {
|
||||
const adapter = logProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.type).toBe("aws_cloudwatch_logs");
|
||||
expect(sink.group_name).toBe("/dokploy/logs");
|
||||
expect(sink.stream_name).toBe("{{ container_name }}");
|
||||
expect(sink.region).toBe("us-east-1");
|
||||
expect(sink.auth).toEqual({
|
||||
access_key_id: "AKIA...",
|
||||
secret_access_key: "shh",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses extraConfig.logStream as the stream template when set", () => {
|
||||
const adapter = logProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
logStream: "{{ dokploy_application }}",
|
||||
},
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
);
|
||||
expect(sink.stream_name).toBe("{{ dokploy_application }}");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: mocks.lookup,
|
||||
}));
|
||||
|
||||
const { logProviderFetch } = await import(
|
||||
"@dokploy/server/services/log-management/types"
|
||||
);
|
||||
|
||||
describe("logProviderFetch — metadata endpoint guard", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.lookup.mockReset();
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
|
||||
await expect(
|
||||
logProviderFetch("http://169.254.169.254/latest/meta-data/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the AWS ECS metadata IPv6 address", async () => {
|
||||
await expect(
|
||||
logProviderFetch("http://[fd00:ec2::254]/v2/credentials"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
|
||||
for (const address of [
|
||||
"[fe80::1]",
|
||||
"[fe90::1]",
|
||||
"[fea0::1]",
|
||||
"[febf::1]",
|
||||
]) {
|
||||
await expect(
|
||||
logProviderFetch(`http://${address}/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
|
||||
await expect(
|
||||
logProviderFetch("http://metadata.google.internal/computeMetadata/v1/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a hostname whose DNS record resolves to a metadata address", async () => {
|
||||
mocks.lookup.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
|
||||
|
||||
await expect(
|
||||
logProviderFetch("http://attacker-controlled.example.com/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a private-network endpoint (self-hosted Loki behind a VPN, say)", async () => {
|
||||
mocks.lookup.mockResolvedValue([{ address: "10.0.5.20", family: 4 }]);
|
||||
|
||||
await logProviderFetch("http://loki.internal.example.com:3100/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a normal public endpoint", async () => {
|
||||
mocks.lookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]);
|
||||
|
||||
await logProviderFetch(
|
||||
"https://api.us-east-1.datadoghq.com/api/v1/validate",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still calls fetch (letting it surface its own error) when DNS resolution fails", async () => {
|
||||
mocks.lookup.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
|
||||
await logProviderFetch("http://does-not-exist.invalid/");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,280 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertValues: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
insert: () => ({
|
||||
values: (v: any) => ({
|
||||
returning: () => Promise.resolve(mocks.insertValues(v)),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (v: any) => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.updateSet(v)),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
logProvider: {
|
||||
findFirst: mocks.findFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { createLogProvider, updateLogProvider } = await import(
|
||||
"@dokploy/server/services/log-management/service"
|
||||
);
|
||||
|
||||
describe("createLogProvider — required credential fields per adapter", () => {
|
||||
it("rejects a loki provider without endpoint", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{ name: "loki-prod", providerType: "loki" } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider without apiKey", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{ name: "dd", providerType: "datadog" } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a betterstack provider missing either field", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
apiKey: "token-only",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a splunk_hec provider without a HEC token", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/token/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/region|log group/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
extraConfig: { apiKey: "sneaky-value" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/api key/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
|
||||
await expect(
|
||||
createLogProvider(
|
||||
{
|
||||
name: "es",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/password.*required/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ logProviderId: "lp-2", name: "cw", providerType: "aws_cloudwatch" },
|
||||
]);
|
||||
const created = await createLogProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.logProviderId).toBe("lp-2");
|
||||
});
|
||||
|
||||
it("accepts a loki provider with endpoint", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ logProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
|
||||
]);
|
||||
const created = await createLogProvider(
|
||||
{
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.logProviderId).toBe("lp-1");
|
||||
expect(mocks.insertValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLogProvider — merges partial update with existing row before validating", () => {
|
||||
it("allows updating just the name without re-sending existing credentials", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "old-name",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([
|
||||
{ logProviderId: "lp-1", name: "new-name" },
|
||||
]);
|
||||
|
||||
const updated = await updateLogProvider("lp-1", { name: "new-name" });
|
||||
expect(updated.name).toBe("new-name");
|
||||
});
|
||||
|
||||
it("rejects clearing the only required field via update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(updateLogProvider("lp-1", { endpoint: null })).rejects.toThrow(
|
||||
/endpoint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.com" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", {
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic", index: "custom-index" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", { extraConfig: { username: "new-user" } });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraConfig: { username: "new-user", index: "custom-index" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ logProviderId: "lp-1" }]);
|
||||
|
||||
await updateLogProvider("lp-1", { extraConfig: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extraConfig: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
logProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateLogProvider("lp-1", { providerType: "loki" }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
});
|
||||
314
apps/dokploy/__test__/log-management/vector-config.test.ts
Normal file
314
apps/dokploy/__test__/log-management/vector-config.test.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findManyProjects: vi.fn(),
|
||||
findEnabledLogProvidersByOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
projects: {
|
||||
findMany: mocks.findManyProjects,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/log-management/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/log-management/service")
|
||||
>()),
|
||||
findEnabledLogProvidersByOrganization:
|
||||
mocks.findEnabledLogProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
|
||||
const { buildVectorConfigYaml } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
describe("buildVectorConfigYaml", () => {
|
||||
it("generates a source, a dokploy_scope transform and one sink per enabled provider", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([
|
||||
{
|
||||
projectId: "project-1",
|
||||
name: "My Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
applications: [
|
||||
{
|
||||
appName: "app-my-app-abc123",
|
||||
applicationId: "application-1",
|
||||
name: "my-app",
|
||||
},
|
||||
],
|
||||
compose: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([
|
||||
{
|
||||
logProviderId: "log-provider-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.data_dir).toBe("/var/lib/vector");
|
||||
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
|
||||
expect(config.transforms.dokploy_scope.type).toBe("remap");
|
||||
expect(config.transforms.dokploy_scope.inputs).toEqual([
|
||||
"docker_logs_source",
|
||||
]);
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'.dokploy_organization = "org-1"',
|
||||
);
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'app_name == "app-my-app-abc123"',
|
||||
);
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'.dokploy_project = "My Project"',
|
||||
);
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'.dokploy_environment = "production"',
|
||||
);
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'.dokploy_application = "my-app"',
|
||||
);
|
||||
|
||||
const sinkIds = Object.keys(config.sinks);
|
||||
expect(sinkIds).toEqual(["sink_log-provider-1"]);
|
||||
expect(config.sinks["sink_log-provider-1"].type).toBe("loki");
|
||||
expect(config.sinks["sink_log-provider-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
});
|
||||
|
||||
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([
|
||||
{
|
||||
logProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
{
|
||||
logProviderId: "bs-1",
|
||||
name: "betterstack",
|
||||
providerType: "betterstack",
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
{
|
||||
logProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
expect(config.transforms["transform_bs-1"]).toBeDefined();
|
||||
expect(config.transforms["transform_bs-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
|
||||
expect(config.transforms["transform_dd-1"]).toBeDefined();
|
||||
expect(config.transforms["transform_dd-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
|
||||
});
|
||||
|
||||
it("escapes double quotes in project/application names to avoid breaking the VRL string literal", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([
|
||||
{
|
||||
projectId: "project-1",
|
||||
name: 'My "Project"',
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
applications: [
|
||||
{
|
||||
appName: "app-abc123",
|
||||
applicationId: "application-1",
|
||||
name: "app",
|
||||
},
|
||||
],
|
||||
compose: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toContain(
|
||||
'.dokploy_project = "My \\"Project\\""',
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces every control character in a project name, not just newlines, before embedding it in VRL", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([
|
||||
{
|
||||
projectId: "project-1",
|
||||
name: "Weird\r\x00Name",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
applications: [
|
||||
{
|
||||
appName: "app-abc123",
|
||||
applicationId: "application-1",
|
||||
name: "app",
|
||||
},
|
||||
],
|
||||
compose: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
const source = config.transforms.dokploy_scope.source as string;
|
||||
|
||||
expect(source).toContain('.dokploy_project = "Weird Name"');
|
||||
});
|
||||
|
||||
it("keeps events without a matching appName unscoped (no crash, no filtering), with the scoping fields defaulted to empty string", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toBe(
|
||||
[
|
||||
'.dokploy_organization = "org-1"',
|
||||
'.dokploy_project = ""',
|
||||
'.dokploy_project_id = ""',
|
||||
'.dokploy_environment = ""',
|
||||
'.dokploy_environment_id = ""',
|
||||
'.dokploy_application = ""',
|
||||
'.dokploy_application_id = ""',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses preloaded org data instead of querying, when given (fan-out reuse)", async () => {
|
||||
mocks.findManyProjects.mockClear();
|
||||
mocks.findEnabledLogProvidersByOrganization.mockClear();
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1", {
|
||||
providers: [],
|
||||
lookup: {},
|
||||
});
|
||||
|
||||
expect(mocks.findManyProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.findEnabledLogProvidersByOrganization).not.toHaveBeenCalled();
|
||||
const config = parse(yamlStr) as any;
|
||||
expect(config.sinks).toEqual({});
|
||||
});
|
||||
|
||||
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml("org-1", {
|
||||
providers: [
|
||||
{
|
||||
logProviderId: "broken-es",
|
||||
name: "es-broken",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
{
|
||||
logProviderId: "good-loki",
|
||||
name: "loki-ok",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
] as any,
|
||||
lookup: {},
|
||||
});
|
||||
|
||||
const config = parse(yamlStr) as any;
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_good-loki"]);
|
||||
});
|
||||
|
||||
it("dropUnmatched (local host only) filters out events with no matching Dokploy app before any sink", async () => {
|
||||
const yamlStr = await buildVectorConfigYaml(
|
||||
"org-1",
|
||||
{
|
||||
providers: [
|
||||
{
|
||||
logProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
},
|
||||
] as any,
|
||||
lookup: {},
|
||||
},
|
||||
{ dropUnmatched: true },
|
||||
);
|
||||
|
||||
const config = parse(yamlStr) as any;
|
||||
expect(config.transforms.dokploy_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_project != ""',
|
||||
});
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add the drop-unmatched filter for the per-server (non-local) path", async () => {
|
||||
mocks.findManyProjects.mockResolvedValue([]);
|
||||
mocks.findEnabledLogProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
const yamlStr = await buildVectorConfigYaml("org-1");
|
||||
const config = parse(yamlStr) as any;
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
244
apps/dokploy/__test__/log-management/vector-setup-fixes.test.ts
Normal file
244
apps/dokploy/__test__/log-management/vector-setup-fixes.test.ts
Normal file
@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
reserve: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { projects: { findMany: vi.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("postgres", () => ({
|
||||
default: vi.fn(() => ({ reserve: mocks.reserve })),
|
||||
}));
|
||||
|
||||
const makeReservedMock = (locked: boolean) => {
|
||||
const tag: any = vi
|
||||
.fn()
|
||||
.mockImplementation((strings: TemplateStringsArray) => {
|
||||
const text = strings.join("");
|
||||
if (text.includes("pg_try_advisory_lock")) {
|
||||
return Promise.resolve([{ locked }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
tag.release = vi.fn();
|
||||
return tag;
|
||||
};
|
||||
|
||||
const {
|
||||
withConfigWriteLock,
|
||||
collectSecretValues,
|
||||
redactSecrets,
|
||||
stripImageDigest,
|
||||
vectorServiceSpecUnchanged,
|
||||
buildServiceSettings,
|
||||
} = await import("@dokploy/server/setup/vector-setup");
|
||||
|
||||
describe("withConfigWriteLock — cross-process guard (session scoped pg_advisory_lock)", () => {
|
||||
beforeEach(() => {
|
||||
mocks.reserve.mockReset();
|
||||
});
|
||||
|
||||
it("runs fn and returns its result when the advisory lock is acquired", async () => {
|
||||
const reserved = makeReservedMock(true);
|
||||
mocks.reserve.mockResolvedValue(reserved);
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
|
||||
await expect(withConfigWriteLock("server-1", fn)).resolves.toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
const joinedCalls = reserved.mock.calls.map((call: any[]) =>
|
||||
(call[0] as string[]).join(""),
|
||||
);
|
||||
expect(
|
||||
joinedCalls.some((text: string) => text.includes("pg_advisory_unlock")),
|
||||
).toBe(true);
|
||||
expect(reserved.release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries, then throws without ever calling fn, when another process keeps holding the lock", async () => {
|
||||
mocks.reserve.mockImplementation(async () => makeReservedMock(false));
|
||||
const fn = vi.fn();
|
||||
|
||||
await expect(withConfigWriteLock("server-1", fn)).rejects.toThrow(
|
||||
/already in progress/i,
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(mocks.reserve).toHaveBeenCalledTimes(3);
|
||||
}, 10_000);
|
||||
|
||||
it("still serializes two calls for the same serverId within this process", async () => {
|
||||
mocks.reserve.mockImplementation(async () => makeReservedMock(true));
|
||||
const order: string[] = [];
|
||||
const slow = async (label: string, ms: number) => {
|
||||
order.push(`${label}:start`);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
order.push(`${label}:end`);
|
||||
return label;
|
||||
};
|
||||
|
||||
const first = withConfigWriteLock("server-a", () => slow("first", 20));
|
||||
const second = withConfigWriteLock("server-a", () => slow("second", 1));
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(order).toEqual([
|
||||
"first:start",
|
||||
"first:end",
|
||||
"second:start",
|
||||
"second:end",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSecretValues / redactSecrets", () => {
|
||||
it("collects endpoint/apiKey/apiSecret and string extraConfig values across all providers", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "loki-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { tenantId: "tenant-a" },
|
||||
},
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
expect(collectSecretValues(providers)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://loki.example.com",
|
||||
"loki-key",
|
||||
"tenant-a",
|
||||
"dd-key",
|
||||
"datadoghq.eu",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-string extraConfig values and null/empty credentials", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { limit: 10, enabled: true, empty: "" },
|
||||
},
|
||||
] as any;
|
||||
expect(collectSecretValues(providers)).toEqual([]);
|
||||
});
|
||||
|
||||
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
|
||||
const providers = [
|
||||
{
|
||||
endpoint: null,
|
||||
apiKey: "hunter2-long-suffix",
|
||||
apiSecret: null,
|
||||
extraConfig: { other: "hunter2" },
|
||||
},
|
||||
] as any;
|
||||
const text =
|
||||
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
|
||||
|
||||
const redacted = redactSecrets(text, collectSecretValues(providers));
|
||||
|
||||
expect(redacted).not.toContain("hunter2");
|
||||
expect(redacted).toBe(
|
||||
"sink validation error near apiKey=[redacted] and [redacted]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageDigest", () => {
|
||||
it("drops an @sha256 digest suffix", () => {
|
||||
expect(
|
||||
stripImageDigest("timberio/vector:latest-alpine@sha256:abc123"),
|
||||
).toBe("timberio/vector:latest-alpine");
|
||||
});
|
||||
|
||||
it("returns the image unchanged when there's no digest", () => {
|
||||
expect(stripImageDigest("timberio/vector:latest-alpine")).toBe(
|
||||
"timberio/vector:latest-alpine",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vectorServiceSpecUnchanged", () => {
|
||||
const settings = {
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: "timberio/vector:latest-alpine",
|
||||
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
|
||||
Mounts: [{ Type: "bind", Source: "/a", Target: "/b" }],
|
||||
},
|
||||
Networks: [{ Target: "host" }],
|
||||
},
|
||||
Mode: { Replicated: { Replicas: 1 } },
|
||||
} as any;
|
||||
|
||||
it("is unchanged when inspect matches settings modulo an image digest suffix and a resolved network ID", () => {
|
||||
const inspect = {
|
||||
Spec: {
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: "timberio/vector:latest-alpine@sha256:deadbeef",
|
||||
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
|
||||
Mounts: [{ Type: "bind", Source: "/a", Target: "/b" }],
|
||||
},
|
||||
Networks: [{ Target: "k1vw5h40twado5nnk9n2hi8nt" }],
|
||||
},
|
||||
Mode: { Replicated: { Replicas: 1 } },
|
||||
},
|
||||
} as any;
|
||||
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("is changed when a bind mount source differs", () => {
|
||||
const inspect = {
|
||||
Spec: {
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: "timberio/vector:latest-alpine",
|
||||
Args: ["--config", "/etc/vector/vector.yaml", "--watch-config"],
|
||||
Mounts: [{ Type: "bind", Source: "/old-path", Target: "/b" }],
|
||||
},
|
||||
Networks: [{ Target: "host" }],
|
||||
},
|
||||
Mode: { Replicated: { Replicas: 1 } },
|
||||
},
|
||||
} as any;
|
||||
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(false);
|
||||
});
|
||||
|
||||
it("is changed when Mode differs", () => {
|
||||
const inspect = {
|
||||
Spec: {
|
||||
TaskTemplate: settings.TaskTemplate,
|
||||
Mode: { Global: {} },
|
||||
},
|
||||
} as any;
|
||||
expect(vectorServiceSpecUnchanged(inspect, settings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildServiceSettings", () => {
|
||||
it("bind-mounts the config directory, not the vector.yaml file itself", () => {
|
||||
const settings = buildServiceSettings("/etc/dokploy/vector") as any;
|
||||
const mounts = settings.TaskTemplate.ContainerSpec.Mounts as Array<{
|
||||
Source: string;
|
||||
Target: string;
|
||||
}>;
|
||||
const configMount = mounts.find((m) => m.Target === "/etc/vector");
|
||||
expect(configMount?.Source).toBe("/etc/dokploy/vector");
|
||||
expect(mounts.some((m) => m.Target === "/etc/vector/vector.yaml")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(settings.TaskTemplate.ContainerSpec.Args).toContain(
|
||||
"/etc/vector/vector.yaml",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getWebServerSettings: vi.fn(),
|
||||
hasEnabledLogProvider: vi.fn(),
|
||||
getService: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/log-management/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/log-management/service")
|
||||
>()),
|
||||
hasEnabledLogProvider: mocks.hasEnabledLogProvider,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: vi.fn().mockResolvedValue({
|
||||
getService: mocks.getService,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { syncWebVectorAgent } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
describe("syncWebVectorAgent — install condition", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("tears down (never installs) when the local toggle is off", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
enableLogManagement: false,
|
||||
logManagementOrganizationId: "org-1",
|
||||
});
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
mocks.getService.mockReturnValue({ remove: mocks.remove });
|
||||
|
||||
const result = await syncWebVectorAgent();
|
||||
|
||||
expect(result.installed).toBe(false);
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
|
||||
expect(mocks.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down when no organization has claimed the local agent yet", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
enableLogManagement: true,
|
||||
logManagementOrganizationId: null,
|
||||
});
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
mocks.getService.mockReturnValue({ remove: mocks.remove });
|
||||
|
||||
const result = await syncWebVectorAgent();
|
||||
|
||||
expect(result.installed).toBe(false);
|
||||
expect(mocks.hasEnabledLogProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tears down when the owning organization has no enabled log provider", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
enableLogManagement: true,
|
||||
logManagementOrganizationId: "org-1",
|
||||
});
|
||||
mocks.hasEnabledLogProvider.mockResolvedValue(false);
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
mocks.getService.mockReturnValue({ remove: mocks.remove });
|
||||
|
||||
const result = await syncWebVectorAgent();
|
||||
|
||||
expect(result.installed).toBe(false);
|
||||
expect(mocks.hasEnabledLogProvider).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
|
||||
it("skips the hasEnabledLogProvider query when providers are already preloaded", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
enableLogManagement: true,
|
||||
logManagementOrganizationId: "org-1",
|
||||
});
|
||||
mocks.remove.mockResolvedValue(undefined);
|
||||
mocks.getService.mockReturnValue({ remove: mocks.remove });
|
||||
|
||||
const result = await syncWebVectorAgent({ providers: [], lookup: {} });
|
||||
|
||||
expect(result.installed).toBe(false);
|
||||
expect(mocks.hasEnabledLogProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses logProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses logProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
checkPermission(ctx, { registry: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied logProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied logProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { logProvider: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
|
||||
@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
"logProvider",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
|
||||
@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => {
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
expect(perms.logProvider.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
enableLogManagement: false,
|
||||
logManagementOrganizationId: null,
|
||||
buildsConcurrency: 1,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
|
||||
@ -40,21 +40,29 @@ describe("pipeBetweenServers", () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("reports a target failure with its stderr", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf x" },
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
it(
|
||||
"reports a target failure with its stderr",
|
||||
async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf x" },
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "exit 2" },
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
it(
|
||||
"reports a source failure",
|
||||
async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "exit 2" },
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
@ -0,0 +1,347 @@
|
||||
import type { LogProviderType } from "@dokploy/server/services/log-management/types";
|
||||
import { AlertTriangle, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
|
||||
|
||||
interface Props {
|
||||
logProviderId?: string;
|
||||
}
|
||||
|
||||
export const HandleLogProvider = ({ logProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [providerType, setProviderType] = useState("");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
|
||||
const [initialFieldValues, setInitialFieldValues] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
|
||||
const { data: provider } = api.logProvider.one.useQuery(
|
||||
{ logProviderId: logProviderId || "" },
|
||||
{ enabled: !!logProviderId },
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync,
|
||||
isPending: isSaving,
|
||||
error,
|
||||
isError,
|
||||
} = logProviderId
|
||||
? api.logProvider.update.useMutation()
|
||||
: api.logProvider.create.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnection,
|
||||
isPending: isTestingRaw,
|
||||
error: testError,
|
||||
isError: testIsError,
|
||||
} = api.logProvider.testConnection.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnectionById,
|
||||
isPending: isTestingById,
|
||||
error: testByIdError,
|
||||
isError: testByIdIsError,
|
||||
} = api.logProvider.testConnectionById.useMutation();
|
||||
|
||||
const selectedType = availableTypes?.find((t) => t.type === providerType);
|
||||
|
||||
const handleProviderTypeChange = (value: string) => {
|
||||
setProviderType(value);
|
||||
setFieldValues({});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
setName(provider.name);
|
||||
setProviderType(provider.providerType);
|
||||
setEnabled(provider.enabled);
|
||||
const extraConfig = (provider.extraConfig ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
setFieldValues(extraConfig);
|
||||
setInitialFieldValues(extraConfig);
|
||||
} else if (isOpen) {
|
||||
setName("");
|
||||
setProviderType("");
|
||||
setEnabled(true);
|
||||
setFieldValues({});
|
||||
setInitialFieldValues({});
|
||||
}
|
||||
}, [provider, isOpen]);
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload: Record<string, unknown> = { name, providerType, enabled };
|
||||
const extraConfig: Record<string, string> = {};
|
||||
let hasExtraConfigFields = false;
|
||||
for (const field of selectedType?.credentialFields ?? []) {
|
||||
const value = fieldValues[field.key];
|
||||
if (TOP_LEVEL_KEYS.has(field.key)) {
|
||||
if (value) {
|
||||
payload[field.key] = value;
|
||||
}
|
||||
} else {
|
||||
hasExtraConfigFields = true;
|
||||
extraConfig[field.key] = value ?? "";
|
||||
}
|
||||
}
|
||||
if (hasExtraConfigFields) {
|
||||
payload.extraConfig = extraConfig;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
const topLevelKeys = (selectedType?.credentialFields ?? [])
|
||||
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedTopLevelKeys = topLevelKeys.filter((key) => !!fieldValues[key]);
|
||||
const extraConfigKeys = (selectedType?.credentialFields ?? [])
|
||||
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedExtraConfigKeys = extraConfigKeys.filter(
|
||||
(key) => (fieldValues[key] ?? "") !== (initialFieldValues[key] ?? ""),
|
||||
);
|
||||
const touchedAnyCredentialField =
|
||||
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
|
||||
const isPartiallyTouched =
|
||||
!!logProviderId &&
|
||||
touchedAnyCredentialField &&
|
||||
touchedTopLevelKeys.length < topLevelKeys.length;
|
||||
|
||||
const onTest = async () => {
|
||||
if (logProviderId && !touchedAnyCredentialField) {
|
||||
await testConnectionById({ logProviderId })
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Connection test failed",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = buildPayload();
|
||||
await testConnection({
|
||||
name: payload.name as string,
|
||||
providerType: payload.providerType as LogProviderType,
|
||||
enabled: payload.enabled as boolean,
|
||||
endpoint: (payload.endpoint as string) ?? null,
|
||||
apiKey: (payload.apiKey as string) ?? null,
|
||||
apiSecret: (payload.apiSecret as string) ?? null,
|
||||
extraConfig: (payload.extraConfig as Record<string, unknown>) ?? null,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Connection test failed");
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const payload = buildPayload();
|
||||
await mutateAsync({
|
||||
...(logProviderId ? { logProviderId } : {}),
|
||||
...payload,
|
||||
} as any)
|
||||
.then((result: any) => {
|
||||
utils.logProvider.all.invalidate();
|
||||
toast.success(
|
||||
logProviderId ? "Log provider updated" : "Log provider added",
|
||||
);
|
||||
if (result?.syncErrors && result.syncErrors.length > 0) {
|
||||
toast.error(
|
||||
`Failed to sync ${result.syncErrors.length} server(s) — they may still be shipping with the old config`,
|
||||
);
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
logProviderId
|
||||
? "Error updating log provider"
|
||||
: "Error adding log provider",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isTesting = isTestingRaw || isTestingById;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{logProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Log Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{logProviderId ? "Edit Log Provider" : "Add a Log Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vector will ship container logs from every server with Log
|
||||
Management enabled to this provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testIsError || testByIdIsError) && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{testError?.message ||
|
||||
testByIdError?.message ||
|
||||
error?.message ||
|
||||
""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 w-full gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. Production Loki"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={providerType}
|
||||
onValueChange={handleProviderTypeChange}
|
||||
disabled={!!logProviderId}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes?.map((type) => (
|
||||
<SelectItem key={type.type} value={type.type}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedType?.credentialFields.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className={
|
||||
field.type === "url" || field.fullWidth
|
||||
? "flex flex-col gap-2 col-span-2"
|
||||
: "flex flex-col gap-2"
|
||||
}
|
||||
>
|
||||
<Label>
|
||||
{field.label}
|
||||
{!field.required && " (Optional)"}
|
||||
</Label>
|
||||
{field.helpText && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{field.helpText}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
type={field.type === "password" ? "password" : "text"}
|
||||
placeholder={
|
||||
logProviderId && TOP_LEVEL_KEYS.has(field.key)
|
||||
? "Leave blank to keep existing"
|
||||
: field.placeholder
|
||||
}
|
||||
autoComplete={
|
||||
field.type === "password" ? "one-time-code" : "off"
|
||||
}
|
||||
value={fieldValues[field.key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-row items-center gap-2 col-span-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label>Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
|
||||
{isPartiallyTouched && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
You changed something that needs a fresh credential to test — fill
|
||||
in every credential field to test with the new values, or revert
|
||||
it to test with what's already saved.
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row gap-2 justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
disabled={!providerType || isPartiallyTouched}
|
||||
onClick={onTest}
|
||||
>
|
||||
Test connection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
isLoading={isSaving}
|
||||
disabled={!providerType || !name || isSaving}
|
||||
>
|
||||
{logProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,247 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleLogProvider } from "./handle-log-provider";
|
||||
|
||||
interface LogProviderRowProps {
|
||||
provider: {
|
||||
logProviderId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
providerType: string;
|
||||
};
|
||||
index: number;
|
||||
typeLabel: string;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const LogProviderRow = ({
|
||||
provider,
|
||||
index,
|
||||
typeLabel,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onDeleted,
|
||||
}: LogProviderRowProps) => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.logProvider.remove.useMutation();
|
||||
const { mutateAsync: testConnectionById, isPending: isTesting } =
|
||||
api.logProvider.testConnectionById.useMutation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium flex items-center gap-2">
|
||||
{index + 1}. {provider.name}
|
||||
<Badge variant={provider.enabled ? "green" : "secondary"}>
|
||||
{provider.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isTesting}
|
||||
onClick={async () => {
|
||||
await testConnectionById({
|
||||
logProviderId: provider.logProviderId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Connection test failed",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<HandleLogProvider logProviderId={provider.logProviderId} />
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Log Provider"
|
||||
description="Are you sure you want to delete this log provider? Vector will stop shipping logs to it."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
logProviderId: provider.logProviderId,
|
||||
})
|
||||
.then((result) => {
|
||||
toast.success("Log provider deleted successfully");
|
||||
if (result.syncErrors && result.syncErrors.length > 0) {
|
||||
toast.error(
|
||||
`Failed to sync ${result.syncErrors.length} server(s) — they may still be shipping with the old config`,
|
||||
);
|
||||
}
|
||||
onDeleted();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting log provider");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowLogProviders = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isPending, refetch } = api.logProvider.all.useQuery();
|
||||
const { data: availableTypes } = api.logProvider.availableTypes.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const providerListLabel = availableTypes?.length
|
||||
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
||||
: "";
|
||||
|
||||
const hasAnyProvider = (data?.length ?? 0) > 0;
|
||||
const knowsAboutServers = servers !== undefined;
|
||||
const hasActiveTarget =
|
||||
!!webServerSettings?.enableLogManagement ||
|
||||
!!servers?.some((s) => s.enableLogManagement);
|
||||
const showNoActiveTargetBanner =
|
||||
hasAnyProvider && knowsAboutServers && !hasActiveTarget;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<ScrollText className="size-6 text-muted-foreground self-center" />
|
||||
Log Management
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{`Ship container logs to an external provider${providerListLabel}. Needs at least one provider here and the toggle enabled on each server's settings.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{showNoActiveTargetBanner && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 mb-4">
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span className="text-yellow-700 dark:text-yellow-400">
|
||||
Nothing will ship yet — no server has Log Management turned
|
||||
on.
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<Link
|
||||
href={
|
||||
isCloud
|
||||
? "/dashboard/settings/servers"
|
||||
: "/dashboard/settings/server"
|
||||
}
|
||||
>
|
||||
{isCloud ? "Go to Servers" : "Go to Web Server settings"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<ScrollText className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any log provider configured
|
||||
</span>
|
||||
{permissions?.logProvider.create && <HandleLogProvider />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
{data?.map((provider, index) => (
|
||||
<LogProviderRow
|
||||
key={provider.logProviderId}
|
||||
provider={provider}
|
||||
index={index}
|
||||
typeLabel={
|
||||
availableTypes?.find(
|
||||
(t) => t.type === provider.providerType,
|
||||
)?.label ?? provider.providerType
|
||||
}
|
||||
canEdit={!!permissions?.logProvider.create}
|
||||
canDelete={!!permissions?.logProvider.delete}
|
||||
onDeleted={() => {
|
||||
refetch();
|
||||
utils.server.all?.invalidate?.();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{permissions?.logProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleLogProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -12,6 +12,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowStorageActions } from "./show-storage-actions";
|
||||
import { ShowTraefikActions } from "./show-traefik-actions";
|
||||
import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
||||
import { ToggleLogManagement } from "./toggle-log-management";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
@ -49,6 +50,7 @@ export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||
<ShowTraefikActions serverId={serverId} />
|
||||
<ShowStorageActions serverId={serverId} />
|
||||
<ToggleDockerCleanup serverId={serverId} />
|
||||
<ToggleLogManagement serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -0,0 +1,125 @@
|
||||
import { HelpCircle, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ToggleLogManagement = ({ serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{ serverId: serverId || "" },
|
||||
{ enabled: !!serverId },
|
||||
);
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
const { mutateAsync: updateServer, isPending: isServerPending } =
|
||||
api.server.updateLogManagement.useMutation();
|
||||
const { mutateAsync: updateLocal, isPending: isLocalPending } =
|
||||
api.settings.updateLogManagement.useMutation();
|
||||
|
||||
const isPending = serverId ? isServerPending : isLocalPending;
|
||||
const enabled = serverId
|
||||
? !!server?.enableLogManagement
|
||||
: !!webServerSettings?.enableLogManagement;
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (serverId) {
|
||||
await updateServer({ serverId, enableLogManagement: checked })
|
||||
.then((result) => {
|
||||
utils.server.one.setData({ serverId }, (old) =>
|
||||
old
|
||||
? { ...old, enableLogManagement: result.enableLogManagement }
|
||||
: old,
|
||||
);
|
||||
if (checked && !result.installed) {
|
||||
toast.message(
|
||||
"Log Management enabled, but no log provider is configured yet — add one under Settings > Log Management.",
|
||||
);
|
||||
} else {
|
||||
toast.success("Log Management updated");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Error updating Log Management",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateLocal({ enableLogManagement: checked })
|
||||
.then((result) => {
|
||||
utils.settings.getWebServerSettings.setData(undefined, (old) =>
|
||||
old
|
||||
? { ...old, enableLogManagement: result.enableLogManagement }
|
||||
: old,
|
||||
);
|
||||
if (checked && !result.installed) {
|
||||
toast.message(
|
||||
"Log Management enabled, but no log provider is configured yet — add one under Settings > Log Management.",
|
||||
);
|
||||
} else {
|
||||
toast.success("Log Management updated");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Error updating Log Management",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
/>
|
||||
{isPending && (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
|
||||
Log Management
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</Label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm flex-col items-start">
|
||||
<p>
|
||||
Ships {serverId ? "this server's" : "this machine's"} container
|
||||
logs to the log providers configured under Settings > Log
|
||||
Management, via a Vector agent. May take a few seconds to install
|
||||
or remove.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Has no effect (and installs nothing) unless at least one log
|
||||
provider is enabled for this organization.
|
||||
</p>
|
||||
{!serverId && (
|
||||
<p className="mt-1">
|
||||
Ships every container on this machine, not just this
|
||||
organization's — if another organization on this instance also
|
||||
deploys here, its logs ship too (unscoped, no project/app tags).
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -109,8 +109,13 @@ export const DeleteServerModal = ({
|
||||
|
||||
const handleDeleteServer = async () => {
|
||||
try {
|
||||
await deleteServer({ serverId });
|
||||
const result = await deleteServer({ serverId });
|
||||
toast.success(`Server ${serverName} deleted successfully`);
|
||||
if (result?.vectorAgentRemovalWarning) {
|
||||
toast.error(
|
||||
`Couldn't remove the Vector log agent from this host — it may still be running there: ${result.vectorAgentRemovalWarning}`,
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
utils.server.all.invalidate();
|
||||
} catch (error: any) {
|
||||
|
||||
@ -126,10 +126,13 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
enableDockerCleanup: data.enableDockerCleanup,
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
.then(async (data) => {
|
||||
await utils.server.all.invalidate();
|
||||
refetchServer();
|
||||
toast.success(serverId ? "Server Updated" : "Server Created");
|
||||
if ("vectorAgentWarning" in data && data.vectorAgentWarning) {
|
||||
toast.error(data.vectorAgentWarning);
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@ -13,6 +13,7 @@ import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||
import { ShowTraefikActions } from "./servers/actions/show-traefik-actions";
|
||||
import { ToggleDockerCleanup } from "./servers/actions/toggle-docker-cleanup";
|
||||
import { ToggleLogManagement } from "./servers/actions/toggle-log-management";
|
||||
import { UpdateServer } from "./web-server/update-server";
|
||||
|
||||
export const WebServer = () => {
|
||||
@ -68,6 +69,7 @@ export const WebServer = () => {
|
||||
</span>
|
||||
|
||||
<ToggleDockerCleanup />
|
||||
<ToggleLogManagement />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
@ -374,6 +375,13 @@ const MENU: Menu = {
|
||||
icon: Package,
|
||||
isEnabled: ({ permissions }) => !!permissions?.registry.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Log Management",
|
||||
url: "/dashboard/settings/log-providers",
|
||||
icon: ScrollText,
|
||||
isEnabled: ({ permissions }) => !!permissions?.logProvider.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Secrets",
|
||||
|
||||
@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
||||
settings: "Settings",
|
||||
session: "Session",
|
||||
network: "Network",
|
||||
logProvider: "Log Provider",
|
||||
};
|
||||
|
||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||
|
||||
@ -180,6 +180,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
||||
},
|
||||
logProvider: {
|
||||
label: "Log Providers",
|
||||
description:
|
||||
"Manage log management providers (Grafana Loki, Datadog, Better Stack) used to ship container logs",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@ -475,6 +480,14 @@ const ACTION_META: Record<
|
||||
description: "Remove DNS providers and delete their records",
|
||||
},
|
||||
},
|
||||
logProvider: {
|
||||
read: { label: "Read", description: "View configured log providers" },
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new log providers and test their connection",
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove log providers" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
|
||||
19
apps/dokploy/drizzle/0197_past_omega_red.sql
Normal file
19
apps/dokploy/drizzle/0197_past_omega_red.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TYPE "public"."LogProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch');--> statement-breakpoint
|
||||
CREATE TABLE "logProvider" (
|
||||
"logProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "LogProviderType" NOT NULL,
|
||||
"endpoint" text,
|
||||
"apiKey" text,
|
||||
"apiSecret" text,
|
||||
"extraConfig" jsonb,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "server" ADD COLUMN "enableLogManagement" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "enableLogManagement" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "logManagementOrganizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "logProvider" ADD CONSTRAINT "logProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_logManagementOrganizationId_organization_id_fk" FOREIGN KEY ("logManagementOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||
9305
apps/dokploy/drizzle/meta/0197_snapshot.json
Normal file
9305
apps/dokploy/drizzle/meta/0197_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1380,6 +1380,13 @@
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1789042957674,
|
||||
"tag": "0197_past_omega_red",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -48,6 +48,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
"@aws-sdk/client-ssm": "3.1108.0",
|
||||
|
||||
55
apps/dokploy/pages/dashboard/settings/log-providers.tsx
Normal file
55
apps/dokploy/pages/dashboard/settings/log-providers.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowLogProviders } from "@/components/dashboard/settings/log-management/show-log-providers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowLogProviders />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Log Management">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { giteaRouter } from "./routers/gitea";
|
||||
import { githubRouter } from "./routers/github";
|
||||
import { gitlabRouter } from "./routers/gitlab";
|
||||
import { libsqlRouter } from "./routers/libsql";
|
||||
import { logProviderRouter } from "./routers/log-provider";
|
||||
import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
@ -85,6 +86,7 @@ export const appRouter = createTRPCRouter({
|
||||
github: githubRouter,
|
||||
gitlab: gitlabRouter,
|
||||
libsql: libsqlRouter,
|
||||
logProvider: logProviderRouter,
|
||||
mariadb: mariadbRouter,
|
||||
mongo: mongoRouter,
|
||||
mounts: mountRouter,
|
||||
|
||||
141
apps/dokploy/server/api/routers/log-provider.ts
Normal file
141
apps/dokploy/server/api/routers/log-provider.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import {
|
||||
createLogProvider,
|
||||
findLogProviderById,
|
||||
findLogProvidersByOrganization,
|
||||
logProviderAdapters,
|
||||
removeLogProvider,
|
||||
sanitizeLogProvider,
|
||||
testLogProviderConnection,
|
||||
updateLogProvider,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import {
|
||||
apiCreateLogProvider,
|
||||
apiFindOneLogProvider,
|
||||
apiRemoveLogProvider,
|
||||
apiTestLogProvider,
|
||||
apiUpdateLogProvider,
|
||||
} from "@/server/db/schema";
|
||||
import { safeSyncVectorAgentsForOrganization } from "@/server/utils/vector-resync";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
|
||||
export const logProviderRouter = createTRPCRouter({
|
||||
create: withPermission("logProvider", "create")
|
||||
.input(apiCreateLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await createLogProvider(
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "logProvider",
|
||||
resourceId: provider.logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
const syncErrors = await safeSyncVectorAgentsForOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return { ...sanitizeLogProvider(provider), syncErrors };
|
||||
}),
|
||||
update: withPermission("logProvider", "create")
|
||||
.input(apiUpdateLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { logProviderId, ...rest } = input;
|
||||
const provider = await findLogProviderById(logProviderId);
|
||||
if (provider.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to update this log provider",
|
||||
});
|
||||
}
|
||||
const updated = await updateLogProvider(logProviderId, rest);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "logProvider",
|
||||
resourceId: logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
const syncErrors = await safeSyncVectorAgentsForOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return { ...sanitizeLogProvider(updated), syncErrors };
|
||||
}),
|
||||
remove: withPermission("logProvider", "delete")
|
||||
.input(apiRemoveLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findLogProviderById(input.logProviderId);
|
||||
if (provider.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to delete this log provider",
|
||||
});
|
||||
}
|
||||
const removed = await removeLogProvider(input.logProviderId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "logProvider",
|
||||
resourceId: provider.logProviderId,
|
||||
resourceName: provider.name,
|
||||
});
|
||||
const syncErrors = await safeSyncVectorAgentsForOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return { ...sanitizeLogProvider(removed), syncErrors };
|
||||
}),
|
||||
all: withPermission("logProvider", "read").query(async ({ ctx }) => {
|
||||
return await findLogProvidersByOrganization(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
one: withPermission("logProvider", "read")
|
||||
.input(apiFindOneLogProvider)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const provider = await findLogProviderById(input.logProviderId);
|
||||
if (provider.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this log provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
}),
|
||||
testConnection: withPermission("logProvider", "create")
|
||||
.input(apiTestLogProvider)
|
||||
.mutation(async ({ input }) => {
|
||||
return await testLogProviderConnection({
|
||||
providerType: input.providerType,
|
||||
config: {
|
||||
logProviderId: "test",
|
||||
name: input.name ?? "test",
|
||||
endpoint: input.endpoint ?? null,
|
||||
apiKey: input.apiKey ?? null,
|
||||
apiSecret: input.apiSecret ?? null,
|
||||
extraConfig: input.extraConfig ?? null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
testConnectionById: withPermission("logProvider", "create")
|
||||
.input(apiFindOneLogProvider)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const provider = await findLogProviderById(input.logProviderId);
|
||||
if (provider.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this log provider",
|
||||
});
|
||||
}
|
||||
return await testLogProviderConnection({
|
||||
logProviderId: input.logProviderId,
|
||||
});
|
||||
}),
|
||||
availableTypes: protectedProcedure.query(() => {
|
||||
return Object.values(logProviderAdapters).map((adapter) => ({
|
||||
type: adapter.type,
|
||||
label: adapter.label,
|
||||
docsUrl: adapter.docsUrl,
|
||||
credentialFields: adapter.credentialFields,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@ -20,6 +20,7 @@ import {
|
||||
organizationRole,
|
||||
user,
|
||||
} from "@/server/db/schema";
|
||||
import { teardownVectorForOrganizationDeletion } from "@/server/utils/vector-resync";
|
||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||
export const organizationRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@ -285,6 +286,8 @@ export const organizationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await teardownVectorForOrganizationDeletion(input.organizationId);
|
||||
|
||||
const result = await db
|
||||
.delete(organization)
|
||||
.where(eq(organization.id, input.organizationId));
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
IS_CLOUD,
|
||||
redactServerSshKey,
|
||||
removeDeploymentsByServerId,
|
||||
removeVectorAgent,
|
||||
serverAudit,
|
||||
serverSetup,
|
||||
serverValidate,
|
||||
@ -50,6 +51,10 @@ import {
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||
import {
|
||||
applyVectorResyncSchedule,
|
||||
syncVectorAgentAndSchedule,
|
||||
} from "@/server/utils/vector-resync";
|
||||
|
||||
export const serverRouter = createTRPCRouter({
|
||||
create: withPermission("server", "create")
|
||||
@ -453,6 +458,73 @@ export const serverRouter = createTRPCRouter({
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
setupLogManagement: withPermission("server", "create")
|
||||
.input(apiFindOneServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to setup this server",
|
||||
});
|
||||
}
|
||||
const result = await syncVectorAgentAndSchedule(input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId,
|
||||
resourceName: server.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
updateLogManagement: withPermission("server", "create")
|
||||
.input(
|
||||
z.object({
|
||||
serverId: z.string().min(1),
|
||||
enableLogManagement: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to update this server",
|
||||
});
|
||||
}
|
||||
await updateServerById(input.serverId, {
|
||||
enableLogManagement: input.enableLogManagement,
|
||||
});
|
||||
try {
|
||||
const result = await syncVectorAgentAndSchedule(input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId,
|
||||
resourceName: server.name,
|
||||
});
|
||||
return { ...result, enableLogManagement: input.enableLogManagement };
|
||||
} catch (error) {
|
||||
await updateServerById(input.serverId, {
|
||||
enableLogManagement: server.enableLogManagement,
|
||||
});
|
||||
await syncVectorAgentAndSchedule(input.serverId).catch(
|
||||
(teardownError) => {
|
||||
console.error(
|
||||
`[Vector] Failed to tear down agent for server ${input.serverId} after a failed sync — it may still be running:`,
|
||||
teardownError,
|
||||
);
|
||||
},
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? `Failed to sync Vector agent: ${error.message}`
|
||||
: "Failed to sync Vector agent",
|
||||
});
|
||||
}
|
||||
}),
|
||||
remove: withPermission("server", "delete")
|
||||
.input(apiRemoveServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@ -473,11 +545,28 @@ export const serverRouter = createTRPCRouter({
|
||||
message: "Server has active services, please delete them first",
|
||||
});
|
||||
}
|
||||
let vectorAgentRemovalWarning: string | undefined;
|
||||
if (currentServer.enableLogManagement) {
|
||||
await removeVectorAgent({ serverId: input.serverId }).catch(
|
||||
(error) => {
|
||||
vectorAgentRemovalWarning =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[Vector] Failed to remove agent for server ${input.serverId} before deletion:`,
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
await applyVectorResyncSchedule(input.serverId, false);
|
||||
}
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "server",
|
||||
resourceId: currentServer.serverId,
|
||||
resourceName: currentServer.name,
|
||||
...(vectorAgentRemovalWarning
|
||||
? { metadata: { vectorAgentRemovalWarning } }
|
||||
: {}),
|
||||
});
|
||||
await removeDeploymentsByServerId(currentServer);
|
||||
await deleteServer(input.serverId);
|
||||
@ -488,7 +577,10 @@ export const serverRouter = createTRPCRouter({
|
||||
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
|
||||
}
|
||||
|
||||
return redactServerSshKey(currentServer);
|
||||
return {
|
||||
...redactServerSshKey(currentServer),
|
||||
vectorAgentRemovalWarning,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@ -511,7 +603,34 @@ export const serverRouter = createTRPCRouter({
|
||||
message: "Server is inactive",
|
||||
});
|
||||
}
|
||||
const currentServer = await updateServerById(input.serverId, {
|
||||
|
||||
const connectionChanged =
|
||||
server.ipAddress !== input.ipAddress ||
|
||||
server.port !== input.port ||
|
||||
server.username !== input.username ||
|
||||
server.sshKeyId !== input.sshKeyId;
|
||||
|
||||
let vectorAgentWarning: string | undefined;
|
||||
const recordVectorWarning = (prefix: string, error: unknown) => {
|
||||
const message = `${prefix}: ${error instanceof Error ? error.message : String(error)}`;
|
||||
vectorAgentWarning = vectorAgentWarning
|
||||
? `${vectorAgentWarning} ${message}`
|
||||
: message;
|
||||
console.error(`[Vector] ${message}`);
|
||||
};
|
||||
|
||||
if (connectionChanged && server.enableLogManagement) {
|
||||
await removeVectorAgent({ serverId: input.serverId }).catch(
|
||||
(error) => {
|
||||
recordVectorWarning(
|
||||
"Failed to remove the agent from the old host",
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let currentServer = await updateServerById(input.serverId, {
|
||||
...input,
|
||||
});
|
||||
|
||||
@ -521,13 +640,37 @@ export const serverRouter = createTRPCRouter({
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
|
||||
if (connectionChanged && server.enableLogManagement) {
|
||||
try {
|
||||
await syncVectorAgentAndSchedule(input.serverId);
|
||||
} catch (error) {
|
||||
currentServer =
|
||||
(await updateServerById(input.serverId, {
|
||||
enableLogManagement: false,
|
||||
})) ?? currentServer;
|
||||
await syncVectorAgentAndSchedule(input.serverId).catch(
|
||||
(teardownError) => {
|
||||
console.error(
|
||||
`[Vector] Failed to tear down agent for server ${input.serverId} after a failed sync — it may still be running:`,
|
||||
teardownError,
|
||||
);
|
||||
},
|
||||
);
|
||||
recordVectorWarning(
|
||||
"Failed to install the agent on the new host, log management has been turned off for this server",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "server",
|
||||
resourceId: input.serverId,
|
||||
resourceName: server.name,
|
||||
...(vectorAgentWarning ? { metadata: { vectorAgentWarning } } : {}),
|
||||
});
|
||||
return currentServer;
|
||||
return { ...currentServer, vectorAgentWarning };
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
checkPortInUse,
|
||||
checkPostgresHealth,
|
||||
checkTraefikHealth,
|
||||
claimWebServerLogManagement,
|
||||
cleanupAll,
|
||||
cleanupAllBackground,
|
||||
cleanupBuilders,
|
||||
@ -71,6 +72,7 @@ import {
|
||||
} from "@/server/db/schema";
|
||||
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
|
||||
import { removeJob, schedule } from "@/server/utils/backup";
|
||||
import { syncWebVectorAgentAndSchedule } from "@/server/utils/vector-resync";
|
||||
import packageInfo from "../../../package.json";
|
||||
import { appRouter } from "../root";
|
||||
import {
|
||||
@ -410,6 +412,75 @@ export const settingsRouter = createTRPCRouter({
|
||||
return true;
|
||||
}),
|
||||
|
||||
updateLogManagement: adminProcedure
|
||||
.input(z.object({ enableLogManagement: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available for self-hosted instances",
|
||||
});
|
||||
}
|
||||
|
||||
const activeOrganizationId = ctx.session.activeOrganizationId;
|
||||
const current = await getWebServerSettings();
|
||||
const currentOwner = current?.logManagementOrganizationId ?? null;
|
||||
|
||||
if (currentOwner && currentOwner !== activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: input.enableLogManagement
|
||||
? "The local Vector agent is already used by another organization on this instance — disable it there first."
|
||||
: "You are not authorized to disable the local Vector agent — it belongs to another organization.",
|
||||
});
|
||||
}
|
||||
|
||||
const previous = {
|
||||
enableLogManagement: !!current?.enableLogManagement,
|
||||
logManagementOrganizationId: currentOwner,
|
||||
};
|
||||
|
||||
const claimed = await claimWebServerLogManagement(
|
||||
activeOrganizationId,
|
||||
input.enableLogManagement,
|
||||
);
|
||||
if (!claimed) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"The local Vector agent was just claimed by another organization — try again.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result =
|
||||
await syncWebVectorAgentAndSchedule(activeOrganizationId);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "settings",
|
||||
resourceName: "log-management",
|
||||
});
|
||||
return { ...result, enableLogManagement: input.enableLogManagement };
|
||||
} catch (error) {
|
||||
await updateWebServerSettings(previous);
|
||||
await syncWebVectorAgentAndSchedule(activeOrganizationId).catch(
|
||||
(teardownError) => {
|
||||
console.error(
|
||||
"[Vector] Failed to reconcile local agent after a failed sync:",
|
||||
teardownError,
|
||||
);
|
||||
},
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? `Failed to sync local Vector agent: ${error.message}`
|
||||
: "Failed to sync local Vector agent",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateRemoteServersOnly: enterpriseProcedure
|
||||
.input(z.object({ remoteServersOnly: z.boolean() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { config } from "dotenv";
|
||||
import next from "next";
|
||||
import packageInfo from "../package.json";
|
||||
import { initVectorResyncSchedules } from "./utils/vector-resync";
|
||||
import { setupDockerContainerLogsWebSocketServer } from "./wss/docker-container-logs";
|
||||
import { setupDockerContainerTerminalWebSocketServer } from "./wss/docker-container-terminal";
|
||||
import { setupDockerStatsMonitoringSocketServer } from "./wss/docker-stats";
|
||||
@ -65,7 +66,10 @@ void app.prepare().then(async () => {
|
||||
await initSchedules();
|
||||
await initCancelDeployments();
|
||||
await initVolumeBackupsCronJobs();
|
||||
await initVectorResyncSchedules();
|
||||
await sendDokployRestartNotifications();
|
||||
} else if (IS_CLOUD) {
|
||||
await initVectorResyncSchedules();
|
||||
}
|
||||
await initEnterpriseBackupCronJobs();
|
||||
|
||||
|
||||
249
apps/dokploy/server/utils/vector-resync.ts
Normal file
249
apps/dokploy/server/utils/vector-resync.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import {
|
||||
findAllServersWithLogManagementEnabled,
|
||||
findServersWithLogManagementEnabled,
|
||||
getWebServerSettings,
|
||||
hasEnabledLogProvider,
|
||||
IS_CLOUD,
|
||||
loadVectorOrgData,
|
||||
removeVectorAgent,
|
||||
removeWebVectorAgent,
|
||||
syncVectorAgent,
|
||||
syncVectorConfig,
|
||||
syncWebVectorAgent,
|
||||
syncWebVectorConfig,
|
||||
updateWebServerSettings,
|
||||
VECTOR_RESYNC_CRON_JOB,
|
||||
type VectorOrgData,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
|
||||
export const applyVectorResyncSchedule = async (
|
||||
serverId: string,
|
||||
enable: boolean,
|
||||
) => {
|
||||
const jobName = `vector-resync:${serverId}`;
|
||||
if (IS_CLOUD) {
|
||||
console.warn(
|
||||
`[Vector] Periodic resync for server ${serverId} is running in-process, not via the Cloud Jobs service (no handler for "vector-resync" yet).`,
|
||||
);
|
||||
}
|
||||
if (enable) {
|
||||
scheduleJob(jobName, VECTOR_RESYNC_CRON_JOB, async () => {
|
||||
try {
|
||||
await syncVectorConfig({ serverId });
|
||||
} catch (error) {
|
||||
console.error(`[Vector] Resync failed for server ${serverId}:`, error);
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
scheduledJobs[jobName]?.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
scheduledJobs[jobName]?.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const WEB_VECTOR_RESYNC_JOB_NAME = "vector-resync-web";
|
||||
|
||||
export const applyWebVectorResyncSchedule = async (
|
||||
organizationId: string,
|
||||
enable: boolean,
|
||||
) => {
|
||||
if (enable) {
|
||||
scheduleJob(
|
||||
WEB_VECTOR_RESYNC_JOB_NAME,
|
||||
VECTOR_RESYNC_CRON_JOB,
|
||||
async () => {
|
||||
try {
|
||||
await syncWebVectorConfig(organizationId);
|
||||
} catch (error) {
|
||||
console.error("[Vector] Local resync failed:", error);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
scheduledJobs[WEB_VECTOR_RESYNC_JOB_NAME]?.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
export const initVectorResyncSchedules = async () => {
|
||||
let servers: Awaited<
|
||||
ReturnType<typeof findAllServersWithLogManagementEnabled>
|
||||
>;
|
||||
try {
|
||||
servers = await findAllServersWithLogManagementEnabled();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Vector] Failed to load servers for resync schedules:",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const hasProviderByOrg = new Map<
|
||||
string,
|
||||
ReturnType<typeof hasEnabledLogProvider>
|
||||
>();
|
||||
const hasProviderCached = (organizationId: string) => {
|
||||
let result = hasProviderByOrg.get(organizationId);
|
||||
if (!result) {
|
||||
result = hasEnabledLogProvider(organizationId);
|
||||
hasProviderByOrg.set(organizationId, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
servers.map(async (s) => {
|
||||
try {
|
||||
if (await hasProviderCached(s.organizationId)) {
|
||||
await applyVectorResyncSchedule(s.serverId, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Vector] Failed to re-register resync schedule for server ${s.serverId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
try {
|
||||
const settings = await getWebServerSettings();
|
||||
const organizationId = settings?.logManagementOrganizationId;
|
||||
if (
|
||||
settings?.enableLogManagement &&
|
||||
organizationId &&
|
||||
(await hasEnabledLogProvider(organizationId))
|
||||
) {
|
||||
await applyWebVectorResyncSchedule(organizationId, true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Vector] Failed to re-register local resync schedule:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const syncVectorAgentAndSchedule = async (
|
||||
serverId: string,
|
||||
preloaded?: VectorOrgData,
|
||||
) => {
|
||||
const { installed } = await syncVectorAgent({ serverId, preloaded });
|
||||
await applyVectorResyncSchedule(serverId, installed);
|
||||
return { installed };
|
||||
};
|
||||
|
||||
export const syncWebVectorAgentAndSchedule = async (
|
||||
organizationId: string,
|
||||
preloaded?: VectorOrgData,
|
||||
) => {
|
||||
const { installed } = await syncWebVectorAgent(preloaded);
|
||||
await applyWebVectorResyncSchedule(organizationId, installed);
|
||||
return { installed };
|
||||
};
|
||||
|
||||
export const syncVectorAgentsForOrganization = async (
|
||||
organizationId: string,
|
||||
): Promise<Array<{ serverId: string; error: string }>> => {
|
||||
const servers = await findServersWithLogManagementEnabled(organizationId);
|
||||
const webOwnsThisOrg =
|
||||
!IS_CLOUD &&
|
||||
(await getWebServerSettings())?.logManagementOrganizationId ===
|
||||
organizationId;
|
||||
|
||||
if (servers.length === 0 && !webOwnsThisOrg) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const preloaded = await loadVectorOrgData(organizationId);
|
||||
|
||||
const targets: Array<{
|
||||
id: string;
|
||||
run: () => Promise<{ installed: boolean }>;
|
||||
}> = [
|
||||
...servers.map((s) => ({
|
||||
id: s.serverId,
|
||||
run: () => syncVectorAgentAndSchedule(s.serverId, preloaded),
|
||||
})),
|
||||
...(webOwnsThisOrg
|
||||
? [
|
||||
{
|
||||
id: "web",
|
||||
run: () => syncWebVectorAgentAndSchedule(organizationId, preloaded),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(targets.map((t) => t.run()));
|
||||
|
||||
const errors: Array<{ serverId: string; error: string }> = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
const target = targets[index];
|
||||
if (target) {
|
||||
errors.push({
|
||||
serverId: target.id,
|
||||
error:
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const safeSyncVectorAgentsForOrganization = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
try {
|
||||
return await syncVectorAgentsForOrganization(organizationId);
|
||||
} catch (error) {
|
||||
return [
|
||||
{
|
||||
serverId: "unknown",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export const teardownVectorForOrganizationDeletion = async (
|
||||
organizationId: string,
|
||||
): Promise<void> => {
|
||||
const servers = await findServersWithLogManagementEnabled(organizationId);
|
||||
await Promise.all(
|
||||
servers.map(async (s) => {
|
||||
await removeVectorAgent({ serverId: s.serverId }).catch((error) => {
|
||||
console.error(
|
||||
`[Vector] Failed to remove agent for server ${s.serverId} before deleting organization ${organizationId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
await applyVectorResyncSchedule(s.serverId, false);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const settings = await getWebServerSettings();
|
||||
if (settings?.logManagementOrganizationId === organizationId) {
|
||||
await removeWebVectorAgent().catch((error) => {
|
||||
console.error(
|
||||
`[Vector] Failed to remove local agent before deleting organization ${organizationId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
await applyWebVectorResyncSchedule(organizationId, false);
|
||||
await updateWebServerSettings({
|
||||
enableLogManagement: false,
|
||||
logManagementOrganizationId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
138
docs/log-management.md
Normal file
138
docs/log-management.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Log Management
|
||||
|
||||
> **Nota para quien mergee esto:** este contenido vive acá porque no había un checkout local del
|
||||
> repo de docs públicas (`docs.dokploy.com`) disponible al escribir esta feature. Migrar este
|
||||
> archivo a ese repo (sección "Core" o similar, junto a Registry/Monitoring) y borrarlo de acá.
|
||||
|
||||
## Qué es
|
||||
|
||||
Dokploy puede shippear los logs de los containers de un server a un proveedor externo de log
|
||||
management — hoy: **Grafana Loki**, **Datadog Logs**, **Better Stack** (ex-Logtail),
|
||||
**Elasticsearch / OpenSearch**, **Splunk (HTTP Event Collector)** y **AWS CloudWatch Logs**. Antes de
|
||||
esta feature, la única forma de ver logs era el stream en vivo por WebSocket en el dashboard, sin
|
||||
historial ni persistencia. Esta feature no reemplaza ese viewer en vivo — sigue funcionando igual
|
||||
— agrega la posibilidad de mandar una copia de los logs a un sistema externo para
|
||||
retención/búsqueda/alerting.
|
||||
|
||||
Por debajo corre [Vector](https://vector.dev) (Apache-2.0), un único binario, como un agente por
|
||||
server registrado. Dokploy no implementa clientes HTTP para cada proveedor — solo traduce
|
||||
credenciales guardadas en el dashboard a un bloque de configuración de Vector (un "sink").
|
||||
|
||||
## Cómo activarla
|
||||
|
||||
Hacen falta **dos cosas**, ambas apagadas por default:
|
||||
|
||||
1. **Al menos un log provider configurado**, en Settings → Log Management. Elegís el tipo
|
||||
(Loki/Datadog/Better Stack/Elasticsearch/OpenSearch/Splunk/AWS CloudWatch), completás las
|
||||
credenciales que pida ese tipo, y podés probar la
|
||||
conexión antes de guardar.
|
||||
2. **El toggle "Log Management" prendido** en cada lugar donde quieras shippear logs:
|
||||
- Por server registrado: en el diálogo de acciones del server ("Server Actions"), al lado del
|
||||
toggle de Docker Cleanup.
|
||||
- **En la máquina donde corre Dokploy** (self-hosted únicamente — no existe en Dokploy Cloud,
|
||||
donde esa máquina es infraestructura de Dokploy, no del cliente): en Settings → Web Server,
|
||||
al lado del toggle de Docker Cleanup local. La primera organización que lo prende "reclama"
|
||||
ese host — otra organización de la misma instancia no puede prenderlo hasta que la primera lo
|
||||
apague (rechazo explícito, no se pisan silenciosamente).
|
||||
|
||||
Si cualquiera de las dos condiciones no se cumple, el agente Vector **no corre en absoluto** en
|
||||
ese host — no consume CPU/memoria/disco, y nada más del dashboard se ve afectado (el streaming en
|
||||
vivo de logs sigue funcionando igual).
|
||||
|
||||
**Host local en una instancia self-hosted multi-organización:** Vector lee el socket de Docker
|
||||
completo, sin distinguir de qué organización es cada container — pero solo el agente local (el
|
||||
registrado por `server` siempre pertenece a una sola organización, sin este problema). Para
|
||||
evitar que le lleguen logs de otra organización a los providers del dueño del host, el pipeline
|
||||
del agente local descarta todo evento que no matchee una app de la organización dueña (filtro
|
||||
`dokploy_scope_local_only` en `vector-setup.ts`) antes de que llegue a cualquier sink — esto
|
||||
también implica que, a diferencia del agente por-server, containers de terceros (no gestionados
|
||||
por Dokploy) corriendo en la misma máquina ya no shippean via el agente local.
|
||||
|
||||
Un log provider es a nivel de organización: si tenés varios servers con el toggle prendido, todos
|
||||
ellos shippean a todos los providers habilitados. No hay (todavía) una asociación
|
||||
provider-por-server — es una mejora futura posible si hace falta más granularidad.
|
||||
|
||||
### Scoping por proyecto/entorno/aplicación
|
||||
|
||||
Aunque Vector ve **todos** los containers del host (no solo los de Dokploy), cada evento que sí
|
||||
corresponde a una app gestionada por Dokploy llega taggeado con:
|
||||
|
||||
- `dokploy_organization` — siempre presente.
|
||||
- `dokploy_project` / `dokploy_project_id`
|
||||
- `dokploy_environment` / `dokploy_environment_id`
|
||||
- `dokploy_application` / `dokploy_application_id`
|
||||
|
||||
Un container de terceros (no gestionado por Dokploy) corriendo en el mismo host sigue
|
||||
shippeando igual, simplemente sin esos 6 campos.
|
||||
|
||||
Esta tabla de scoping se recalcula cada vez que se toca algo de log management (activar/crear/
|
||||
editar un provider, prender el toggle de un server) y además cada 15 minutos como red de
|
||||
seguridad, para que una app creada después de la configuración inicial no quede sin estos tags
|
||||
indefinidamente.
|
||||
|
||||
## Cómo agregar un provider nuevo (guía para contribuidores)
|
||||
|
||||
Toda la lógica de un provider vive en un solo archivo:
|
||||
`packages/server/src/services/log-management/providers/<nombre>.ts`, implementando la interfaz
|
||||
`LogProviderAdapter` (`packages/server/src/services/log-management/types.ts`). **No hace falta
|
||||
tocar el router tRPC ni la UI** — la UI (`logProvider.availableTypes`) lista los providers
|
||||
disponibles dinámicamente a partir del registry de adapters, y el orquestador de Vector
|
||||
(`packages/server/src/setup/vector-setup.ts`) resuelve el resto de forma genérica.
|
||||
|
||||
Hay dos caminos, según si Vector ya tiene un sink nativo para tu backend:
|
||||
|
||||
### Camino 1: tu backend ya tiene un sink en Vector
|
||||
|
||||
Es el caso de Loki, Datadog, Elasticsearch/OpenSearch, Splunk HEC y AWS CloudWatch Logs. Mirá la
|
||||
[lista de sinks de Vector](https://vector.dev/docs/reference/configuration/sinks/)
|
||||
— si tu backend está ahí, este es tu camino.
|
||||
|
||||
1. Agregar el tipo al enum de Drizzle: `logProviderType` en
|
||||
`packages/server/src/db/schema/log-provider.ts`, + correr
|
||||
`pnpm --filter dokploy migration:generate` y commitear el SQL generado.
|
||||
2. Ampliar el union `LogProviderType` en `services/log-management/types.ts`.
|
||||
3. Crear `providers/<nombre>.ts`:
|
||||
- `credentialFields`: qué campos pedirle al usuario. `key` tiene que ser exactamente
|
||||
`"endpoint"` | `"apiKey"` | `"apiSecret"` (las 3 únicas columnas con espacio propio en la
|
||||
tabla) o, si tu backend necesita algo que no encaja en esas 3, cualquier otro nombre — ese
|
||||
valor termina en `extraConfig` (jsonb). **Este `key` no es solo para mostrar en la UI: la UI
|
||||
genérica lo usa para decidir dónde guardar el valor** (columna directa vs. `extraConfig`) —
|
||||
ver `providers/loki.ts` para el caso simple (`endpoint`) y `providers/betterstack.ts` para
|
||||
el comentario completo sobre este punto (ahí hubo un bug real por esto durante el desarrollo:
|
||||
usar un `key` "bonito" que no coincidía con la columna real).
|
||||
- `toVectorSink(config, sinkId, inputId)`: devolvé el bloque de sink de Vector con tus
|
||||
credenciales mapeadas. **Siempre incluir `buffer: { type: "disk", max_size, when_full }`**
|
||||
(ver `DEFAULT_DISK_BUFFER` en `types.ts` para el default) — el default de Vector es buffer
|
||||
en memoria, que no protege contra un backend caído.
|
||||
- `testConnection(config)` (opcional pero recomendado): un ping barato para validar
|
||||
credenciales antes de guardar.
|
||||
4. Registrar en `providers/registry.ts` (`logProviderAdapters`) — una línea.
|
||||
5. Tests unitarios junto a los de `loki`/`datadog`/`betterstack`
|
||||
(`apps/dokploy/__test__/log-management/log-provider-adapters.test.ts`) verificando la forma
|
||||
del sink generado.
|
||||
|
||||
### Camino 2: tu backend NO tiene sink nativo en Vector
|
||||
|
||||
Es el caso de Better Stack — usar `providers/betterstack.ts` como plantilla. Mismos pasos que
|
||||
arriba, con dos diferencias:
|
||||
|
||||
- El sink usa el genérico `http` (o `socket`/`file` si tu backend expone algo distinto a HTTP).
|
||||
- Si tu backend espera el evento en una forma distinta a la que produce el scoping (ej. un
|
||||
nombre de campo distinto para el timestamp), implementar `toVectorTransform(config,
|
||||
transformId, scopeTransformId)` — un paso VRL intermedio entre el scoping y tu sink. El
|
||||
orquestador (`vector-setup.ts`) ya sabe encadenar `scope → tu transform → tu sink` cuando este
|
||||
método existe, y `scope → sink` directo cuando no — no hay que tocar el orquestador.
|
||||
|
||||
## Decisiones y límites conocidos
|
||||
|
||||
- **Un server = un nodo Swarm de un solo nodo, típicamente.** Dokploy no modela clusters Swarm
|
||||
multi-nodo (cada `server` registrado es una máquina SSH independiente). Si tu server participa
|
||||
de un swarm con workers que no están registrados como su propio `server` en Dokploy, el agente
|
||||
Vector puede terminar programado en un nodo sin la config — fuera de alcance del MVP actual.
|
||||
- **No hay viewer de logs históricos embebido en el dashboard** — esta feature es solo shipping.
|
||||
Para ver los logs, usar la UI del provider externo (Grafana, Datadog, Better Stack, Kibana/
|
||||
OpenSearch Dashboards, Splunk, CloudWatch Logs Insights).
|
||||
- **`logProvider` es free tier**, no enterprise-only — a diferencia de `registry`/`server`/
|
||||
`domain`/`monitoring`, disponible para cualquier organización sin importar el plan.
|
||||
- **Deployment en la misma máquina de Dokploy**: soportado self-hosted (Settings → Web Server),
|
||||
nunca en Dokploy Cloud — ver "Cómo activarla" arriba para el caveat multi-organización.
|
||||
@ -37,6 +37,7 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.1108.0",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
"@aws-sdk/client-ssm": "3.1108.0",
|
||||
|
||||
@ -13,6 +13,8 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
|
||||
export const VECTOR_RESYNC_CRON_JOB = "*/15 * * * *";
|
||||
|
||||
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
|
||||
const parseByteSize = (envVar: string, fallback: number): number => {
|
||||
const raw = process.env[envVar];
|
||||
@ -134,6 +136,7 @@ export const paths = (isServer = false) => {
|
||||
CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
|
||||
MONITORING_PATH: `${BASE_PATH}/monitoring`,
|
||||
REGISTRY_PATH: `${BASE_PATH}/registry`,
|
||||
VECTOR_PATH: `${BASE_PATH}/vector`,
|
||||
SCHEDULES_PATH: `${BASE_PATH}/schedules`,
|
||||
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
|
||||
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
|
||||
|
||||
@ -94,4 +94,5 @@ export type AuditResourceType =
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider";
|
||||
| "dnsProvider"
|
||||
| "logProvider";
|
||||
|
||||
@ -17,6 +17,7 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./log-provider";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
|
||||
87
packages/server/src/db/schema/log-provider.ts
Normal file
87
packages/server/src/db/schema/log-provider.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { encryptedText } from "./utils";
|
||||
|
||||
export const logProviderType = pgEnum("LogProviderType", [
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch",
|
||||
]);
|
||||
|
||||
export const logProvider = pgTable("logProvider", {
|
||||
logProviderId: text("logProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: logProviderType("providerType").notNull(),
|
||||
endpoint: encryptedText("endpoint"),
|
||||
apiKey: encryptedText("apiKey"),
|
||||
apiSecret: encryptedText("apiSecret"),
|
||||
extraConfig: jsonb("extraConfig").$type<Record<string, unknown>>(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
const createSchema = createInsertSchema(logProvider, {
|
||||
logProviderId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
providerType: z.enum([
|
||||
"loki",
|
||||
"datadog",
|
||||
"betterstack",
|
||||
"elasticsearch",
|
||||
"splunk_hec",
|
||||
"aws_cloudwatch",
|
||||
]),
|
||||
endpoint: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((value) => !/\s/.test(value), {
|
||||
message: "Endpoint cannot contain whitespace",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
apiKey: z.string().min(1).nullable().optional(),
|
||||
apiSecret: z.string().min(1).nullable().optional(),
|
||||
extraConfig: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
organizationId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiCreateLogProvider = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
providerType: true,
|
||||
endpoint: true,
|
||||
apiKey: true,
|
||||
apiSecret: true,
|
||||
extraConfig: true,
|
||||
enabled: true,
|
||||
})
|
||||
.required({ name: true, providerType: true });
|
||||
|
||||
export const apiUpdateLogProvider = apiCreateLogProvider.partial().extend({
|
||||
logProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveLogProvider = z.object({
|
||||
logProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneLogProvider = z.object({
|
||||
logProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestLogProvider = apiCreateLogProvider.partial({ name: true });
|
||||
@ -42,6 +42,7 @@ export const server = pgTable("server", {
|
||||
.notNull()
|
||||
.$defaultFn(() => generateAppName("server")),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
||||
enableLogManagement: boolean("enableLogManagement").notNull().default(false),
|
||||
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
organizationId: text("organizationId")
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { certificateType } from "./shared";
|
||||
|
||||
export const webServerSettings = pgTable("webServerSettings", {
|
||||
@ -26,6 +27,11 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||
enableLogManagement: boolean("enableLogManagement").notNull().default(false),
|
||||
logManagementOrganizationId: text("logManagementOrganizationId").references(
|
||||
() => organization.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
// Metrics Configuration
|
||||
metricsConfig: jsonb("metricsConfig")
|
||||
.$type<{
|
||||
|
||||
@ -27,6 +27,9 @@ export * from "./services/gitea";
|
||||
export * from "./services/github";
|
||||
export * from "./services/gitlab";
|
||||
export * from "./services/libsql";
|
||||
export * from "./services/log-management/providers/registry";
|
||||
export * from "./services/log-management/service";
|
||||
export * from "./services/log-management/types";
|
||||
export * from "./services/mariadb";
|
||||
export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
@ -68,6 +71,7 @@ export * from "./setup/server-setup";
|
||||
export * from "./setup/server-validate";
|
||||
export * from "./setup/setup";
|
||||
export * from "./setup/traefik-setup";
|
||||
export * from "./setup/vector-setup";
|
||||
export * from "./templates/processors";
|
||||
export * from "./utils/access-log/handler";
|
||||
export {
|
||||
|
||||
@ -28,6 +28,7 @@ export const statements = {
|
||||
gitProviders: ["read", "create", "delete"],
|
||||
traefikFiles: ["read", "write"],
|
||||
api: ["read"],
|
||||
logProvider: ["read", "create", "delete"],
|
||||
|
||||
// Enterprise-only resources (custom roles only)
|
||||
volume: ["read", "create", "delete"],
|
||||
@ -119,6 +120,7 @@ export const ownerRole = ac.newRole({
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
logProvider: ["read", "create", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -158,6 +160,7 @@ export const adminRole = ac.newRole({
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
logProvider: ["read", "create", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@ -198,6 +201,7 @@ export const memberRole = ac.newRole({
|
||||
certificate: [],
|
||||
destination: [],
|
||||
notification: [],
|
||||
logProvider: [],
|
||||
tag: ["read"],
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
|
||||
@ -0,0 +1,114 @@
|
||||
import {
|
||||
CloudWatchLogsClient,
|
||||
DescribeLogGroupsCommand,
|
||||
} from "@aws-sdk/client-cloudwatch-logs";
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
} from "../types";
|
||||
import { DEFAULT_DISK_BUFFER, LOG_PROVIDER_REQUEST_TIMEOUT_MS } from "../types";
|
||||
|
||||
const DEFAULT_STREAM_TEMPLATE = "{{ container_name }}";
|
||||
|
||||
export const awsCloudwatchAdapter: LogProviderAdapter = {
|
||||
type: "aws_cloudwatch",
|
||||
label: "AWS CloudWatch Logs",
|
||||
docsUrl:
|
||||
"https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "Access Key ID",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "apiSecret",
|
||||
label: "Secret Access Key",
|
||||
type: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "Region",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "us-east-1",
|
||||
},
|
||||
{
|
||||
key: "logGroup",
|
||||
label: "Log Group Name",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "/dokploy/logs",
|
||||
helpText: "Created automatically on first write if it doesn't exist.",
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
key: "logStream",
|
||||
label: "Log Stream Name Template",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: DEFAULT_STREAM_TEMPLATE,
|
||||
helpText:
|
||||
"Vector event template. Defaults to one stream per container name if left blank.",
|
||||
fullWidth: true,
|
||||
},
|
||||
],
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
const logGroup = config.extraConfig?.logGroup;
|
||||
const logStream = config.extraConfig?.logStream;
|
||||
const region = config.extraConfig?.region;
|
||||
return {
|
||||
type: "aws_cloudwatch_logs",
|
||||
inputs: [inputId],
|
||||
group_name: typeof logGroup === "string" ? logGroup : "",
|
||||
stream_name:
|
||||
typeof logStream === "string" && logStream.length > 0
|
||||
? logStream
|
||||
: DEFAULT_STREAM_TEMPLATE,
|
||||
region: typeof region === "string" ? region : "",
|
||||
encoding: { codec: "json" },
|
||||
auth: {
|
||||
access_key_id: config.apiKey ?? "",
|
||||
secret_access_key: config.apiSecret ?? "",
|
||||
},
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
const region = config.extraConfig?.region;
|
||||
const logGroup = config.extraConfig?.logGroup;
|
||||
if (
|
||||
!config.apiKey ||
|
||||
!config.apiSecret ||
|
||||
typeof region !== "string" ||
|
||||
!region ||
|
||||
typeof logGroup !== "string" ||
|
||||
!logGroup
|
||||
) {
|
||||
throw new Error(
|
||||
"AWS access key, secret key, region and log group are required",
|
||||
);
|
||||
}
|
||||
const client = new CloudWatchLogsClient({
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: config.apiKey,
|
||||
secretAccessKey: config.apiSecret,
|
||||
},
|
||||
});
|
||||
await client.send(
|
||||
new DescribeLogGroupsCommand({
|
||||
logGroupNamePrefix: logGroup,
|
||||
limit: 1,
|
||||
}),
|
||||
{ abortSignal: AbortSignal.timeout(LOG_PROVIDER_REQUEST_TIMEOUT_MS) },
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
VectorTransformConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_DISK_BUFFER,
|
||||
logProviderFetch,
|
||||
normalizeEndpointUrl,
|
||||
} from "../types";
|
||||
|
||||
export const betterStackAdapter: LogProviderAdapter = {
|
||||
type: "betterstack",
|
||||
label: "Better Stack (Logtail)",
|
||||
docsUrl: "https://betterstack.com/docs/logs/vector/",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "Source Token",
|
||||
type: "password",
|
||||
required: true,
|
||||
helpText:
|
||||
"Bearer token from your Better Stack source. Testing the connection sends a real test log to your account.",
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Ingesting Host",
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: "in.logs.betterstack.com",
|
||||
helpText:
|
||||
"Shown next to the Source Token when you create the source. Paste it as-is, no https:// needed.",
|
||||
},
|
||||
],
|
||||
toVectorTransform(
|
||||
_config: LogProviderRuntimeConfig,
|
||||
_transformId: string,
|
||||
scopeTransformId: string,
|
||||
): VectorTransformConfig {
|
||||
return {
|
||||
type: "remap",
|
||||
inputs: [scopeTransformId],
|
||||
source: ".dt = del(.timestamp)",
|
||||
};
|
||||
},
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
return {
|
||||
type: "http",
|
||||
inputs: [inputId],
|
||||
method: "post",
|
||||
uri: `${normalizeEndpointUrl(config.endpoint ?? "").replace(/\/$/, "")}/`,
|
||||
encoding: { codec: "json" },
|
||||
compression: "gzip",
|
||||
auth: { strategy: "bearer", token: config.apiKey ?? "" },
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
if (!config.endpoint || !config.apiKey) {
|
||||
throw new Error(
|
||||
"Better Stack ingestingHost and sourceToken are required",
|
||||
);
|
||||
}
|
||||
const uri = `${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/`;
|
||||
const response = await logProviderFetch(uri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: "dokploy-log-provider-test",
|
||||
dokploy_test: true,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Better Stack test event failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
VectorTransformConfig,
|
||||
} from "../types";
|
||||
import { DEFAULT_DISK_BUFFER, logProviderFetch } from "../types";
|
||||
|
||||
const DDTAGS_VRL = `
|
||||
tags = []
|
||||
if .dokploy_project != "" { tags = push(tags, "dokploy_project:" + replace(to_string!(.dokploy_project), ",", "_")) }
|
||||
if .dokploy_environment != "" { tags = push(tags, "dokploy_environment:" + replace(to_string!(.dokploy_environment), ",", "_")) }
|
||||
if .dokploy_application != "" { tags = push(tags, "dokploy_application:" + replace(to_string!(.dokploy_application), ",", "_")) }
|
||||
if .dokploy_organization != "" { tags = push(tags, "dokploy_organization:" + replace(to_string!(.dokploy_organization), ",", "_")) }
|
||||
.ddtags = join!(tags, ",")
|
||||
`.trim();
|
||||
|
||||
const resolveSite = (config: LogProviderRuntimeConfig): string => {
|
||||
const raw = config.extraConfig?.site;
|
||||
if (typeof raw !== "string" || raw.length === 0) return "datadoghq.com";
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
export const datadogAdapter: LogProviderAdapter = {
|
||||
type: "datadog",
|
||||
label: "Datadog Logs",
|
||||
docsUrl: "https://docs.datadoghq.com/logs/",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "API Key",
|
||||
type: "password",
|
||||
required: true,
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
key: "site",
|
||||
label: "Site",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: "datadoghq.com",
|
||||
helpText: "E.g. datadoghq.com (US1) or datadoghq.eu (EU).",
|
||||
fullWidth: true,
|
||||
},
|
||||
],
|
||||
toVectorTransform(
|
||||
_config: LogProviderRuntimeConfig,
|
||||
_transformId: string,
|
||||
scopeTransformId: string,
|
||||
): VectorTransformConfig {
|
||||
return {
|
||||
type: "remap",
|
||||
inputs: [scopeTransformId],
|
||||
source: DDTAGS_VRL,
|
||||
};
|
||||
},
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
return {
|
||||
type: "datadog_logs",
|
||||
inputs: [inputId],
|
||||
default_api_key: config.apiKey ?? "",
|
||||
site: resolveSite(config),
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
if (!config.apiKey) {
|
||||
throw new Error("Datadog API key is required");
|
||||
}
|
||||
const site = resolveSite(config);
|
||||
const response = await logProviderFetch(
|
||||
`https://api.${site}/api/v1/validate`,
|
||||
{
|
||||
headers: { "DD-API-KEY": config.apiKey },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Datadog API key validation failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,153 @@
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
VectorTransformConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_DISK_BUFFER,
|
||||
logProviderFetch,
|
||||
normalizeEndpointUrl,
|
||||
} from "../types";
|
||||
|
||||
const LABEL_TO_JSON_VRL = `
|
||||
if exists(.label) { .label = encode_json(.label) }
|
||||
`.trim();
|
||||
|
||||
type AuthMode =
|
||||
| { kind: "basic"; username: string; password: string }
|
||||
| { kind: "apiKey"; apiKey: string }
|
||||
| { kind: "none" };
|
||||
|
||||
const resolveAuthMode = (config: LogProviderRuntimeConfig): AuthMode => {
|
||||
const username = config.extraConfig?.username;
|
||||
const hasUsername = typeof username === "string" && username.length > 0;
|
||||
if (hasUsername && !config.apiKey) {
|
||||
throw new Error(
|
||||
"A Password / API Key is required when Username is set (leave both blank for no auth).",
|
||||
);
|
||||
}
|
||||
if (hasUsername && config.apiKey) {
|
||||
return {
|
||||
kind: "basic",
|
||||
username: username as string,
|
||||
password: config.apiKey,
|
||||
};
|
||||
}
|
||||
if (config.apiKey) {
|
||||
return { kind: "apiKey", apiKey: config.apiKey };
|
||||
}
|
||||
return { kind: "none" };
|
||||
};
|
||||
|
||||
const buildAuthAndHeaders = (
|
||||
config: LogProviderRuntimeConfig,
|
||||
): Record<string, unknown> => {
|
||||
const mode = resolveAuthMode(config);
|
||||
if (mode.kind === "basic") {
|
||||
return {
|
||||
auth: { strategy: "basic", user: mode.username, password: mode.password },
|
||||
};
|
||||
}
|
||||
if (mode.kind === "apiKey") {
|
||||
return {
|
||||
request: { headers: { Authorization: `ApiKey ${mode.apiKey}` } },
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export const elasticsearchAdapter: LogProviderAdapter = {
|
||||
type: "elasticsearch",
|
||||
label: "Elasticsearch / OpenSearch",
|
||||
docsUrl:
|
||||
"https://vector.dev/docs/reference/configuration/sinks/elasticsearch/",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: "https://elasticsearch.example.com:9200",
|
||||
},
|
||||
{
|
||||
key: "username",
|
||||
label: "Username",
|
||||
type: "text",
|
||||
required: false,
|
||||
helpText: "Leave blank to use an API key instead of basic auth.",
|
||||
},
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "Password / API Key",
|
||||
type: "password",
|
||||
required: false,
|
||||
helpText:
|
||||
"Password for Username, or an API key alone. Leave both blank for no auth.",
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
key: "index",
|
||||
label: "Index",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: "vector-%Y.%m.%d",
|
||||
helpText: "Defaults to vector-%Y.%m.%d, a daily index.",
|
||||
fullWidth: true,
|
||||
},
|
||||
],
|
||||
toVectorTransform(
|
||||
_config: LogProviderRuntimeConfig,
|
||||
_transformId: string,
|
||||
scopeTransformId: string,
|
||||
): VectorTransformConfig {
|
||||
return {
|
||||
type: "remap",
|
||||
inputs: [scopeTransformId],
|
||||
source: LABEL_TO_JSON_VRL,
|
||||
};
|
||||
},
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
const index = config.extraConfig?.index;
|
||||
return {
|
||||
type: "elasticsearch",
|
||||
inputs: [inputId],
|
||||
endpoints: [normalizeEndpointUrl(config.endpoint ?? "")],
|
||||
...(typeof index === "string" && index.length > 0
|
||||
? { bulk: { index } }
|
||||
: {}),
|
||||
...buildAuthAndHeaders(config),
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
if (!config.endpoint) {
|
||||
throw new Error("Elasticsearch/OpenSearch endpoint is required");
|
||||
}
|
||||
const mode = resolveAuthMode(config);
|
||||
const headers: Record<string, string> = {};
|
||||
if (mode.kind === "basic") {
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
`${mode.username}:${mode.password}`,
|
||||
).toString("base64")}`;
|
||||
} else if (mode.kind === "apiKey") {
|
||||
headers.Authorization = `ApiKey ${mode.apiKey}`;
|
||||
}
|
||||
const response = await logProviderFetch(
|
||||
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/_cluster/health`,
|
||||
{ headers },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Elasticsearch/OpenSearch health check failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
validateConfig(config: LogProviderRuntimeConfig): void {
|
||||
resolveAuthMode(config);
|
||||
},
|
||||
};
|
||||
119
packages/server/src/services/log-management/providers/loki.ts
Normal file
119
packages/server/src/services/log-management/providers/loki.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_DISK_BUFFER,
|
||||
logProviderFetch,
|
||||
normalizeEndpointUrl,
|
||||
} from "../types";
|
||||
|
||||
export const lokiAdapter: LogProviderAdapter = {
|
||||
type: "loki",
|
||||
label: "Grafana Loki",
|
||||
docsUrl: "https://grafana.com/docs/loki/latest/",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: "https://loki.example.com",
|
||||
helpText: "Base URL of your Loki instance (push API).",
|
||||
},
|
||||
{
|
||||
key: "username",
|
||||
label: "User / Instance ID",
|
||||
type: "text",
|
||||
required: false,
|
||||
helpText:
|
||||
"Leave blank for an unauthenticated Loki. For Grafana Cloud, this is your numeric Instance ID.",
|
||||
},
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "Password / API Token",
|
||||
type: "password",
|
||||
required: false,
|
||||
helpText:
|
||||
"Leave blank for an unauthenticated Loki. For Grafana Cloud, use an API token here.",
|
||||
},
|
||||
{
|
||||
key: "tenantId",
|
||||
label: "Tenant ID",
|
||||
type: "text",
|
||||
required: false,
|
||||
helpText: "Only if your Loki runs in multi-tenant mode.",
|
||||
fullWidth: true,
|
||||
},
|
||||
],
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
const tenantId = config.extraConfig?.tenantId;
|
||||
const username = config.extraConfig?.username;
|
||||
return {
|
||||
type: "loki",
|
||||
inputs: [inputId],
|
||||
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
|
||||
encoding: { codec: "json" },
|
||||
labels: {
|
||||
dokploy_project: "{{ dokploy_project }}",
|
||||
dokploy_application: "{{ dokploy_application }}",
|
||||
dokploy_organization: "{{ dokploy_organization }}",
|
||||
},
|
||||
...(typeof tenantId === "string" && tenantId.length > 0
|
||||
? { tenant_id: tenantId }
|
||||
: {}),
|
||||
...(config.apiKey
|
||||
? {
|
||||
auth: {
|
||||
strategy: "basic",
|
||||
user: typeof username === "string" ? username : "",
|
||||
password: config.apiKey,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
if (!config.endpoint) {
|
||||
throw new Error("Loki endpoint is required");
|
||||
}
|
||||
const username = config.extraConfig?.username;
|
||||
const tenantId = config.extraConfig?.tenantId;
|
||||
const response = await logProviderFetch(
|
||||
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/loki/api/v1/push`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(config.apiKey
|
||||
? {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${typeof username === "string" ? username : ""}:${config.apiKey}`,
|
||||
).toString("base64")}`,
|
||||
}
|
||||
: {}),
|
||||
...(typeof tenantId === "string" && tenantId.length > 0
|
||||
? { "X-Scope-OrgID": tenantId }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
streams: [
|
||||
{
|
||||
stream: { dokploy_test: "true" },
|
||||
values: [[`${Date.now()}000000`, "dokploy-log-provider-test"]],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Loki push test failed with status ${response.status}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import type { LogProviderAdapter, LogProviderType } from "../types";
|
||||
import { awsCloudwatchAdapter } from "./aws-cloudwatch";
|
||||
import { betterStackAdapter } from "./betterstack";
|
||||
import { datadogAdapter } from "./datadog";
|
||||
import { elasticsearchAdapter } from "./elasticsearch";
|
||||
import { lokiAdapter } from "./loki";
|
||||
import { splunkAdapter } from "./splunk";
|
||||
|
||||
export const logProviderAdapters: Record<LogProviderType, LogProviderAdapter> =
|
||||
{
|
||||
loki: lokiAdapter,
|
||||
datadog: datadogAdapter,
|
||||
betterstack: betterStackAdapter,
|
||||
elasticsearch: elasticsearchAdapter,
|
||||
splunk_hec: splunkAdapter,
|
||||
aws_cloudwatch: awsCloudwatchAdapter,
|
||||
};
|
||||
|
||||
export function getLogProviderAdapter(
|
||||
type: LogProviderType,
|
||||
): LogProviderAdapter {
|
||||
const adapter = logProviderAdapters[type];
|
||||
if (!adapter) {
|
||||
throw new Error(`No LogProviderAdapter registered for type "${type}"`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import type {
|
||||
LogProviderAdapter,
|
||||
LogProviderRuntimeConfig,
|
||||
VectorSinkConfig,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_DISK_BUFFER,
|
||||
logProviderFetch,
|
||||
normalizeEndpointUrl,
|
||||
} from "../types";
|
||||
|
||||
export const splunkAdapter: LogProviderAdapter = {
|
||||
type: "splunk_hec",
|
||||
label: "Splunk HTTP Event Collector",
|
||||
docsUrl:
|
||||
"https://vector.dev/docs/reference/configuration/sinks/splunk_hec_logs/",
|
||||
credentialFields: [
|
||||
{
|
||||
key: "endpoint",
|
||||
label: "Endpoint",
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: "https://splunk.example.com:8088",
|
||||
helpText: "Base URL of your Splunk instance, including the HEC port.",
|
||||
},
|
||||
{
|
||||
key: "apiKey",
|
||||
label: "HEC Token",
|
||||
type: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "index",
|
||||
label: "Index",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "sourcetype",
|
||||
label: "Source Type",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: "httpevent",
|
||||
},
|
||||
],
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
_sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig {
|
||||
const index = config.extraConfig?.index;
|
||||
const sourcetype = config.extraConfig?.sourcetype;
|
||||
return {
|
||||
type: "splunk_hec_logs",
|
||||
inputs: [inputId],
|
||||
endpoint: normalizeEndpointUrl(config.endpoint ?? ""),
|
||||
default_token: config.apiKey ?? "",
|
||||
encoding: { codec: "json" },
|
||||
...(typeof index === "string" && index.length > 0 ? { index } : {}),
|
||||
...(typeof sourcetype === "string" && sourcetype.length > 0
|
||||
? { sourcetype }
|
||||
: {}),
|
||||
buffer: DEFAULT_DISK_BUFFER,
|
||||
};
|
||||
},
|
||||
async testConnection(config: LogProviderRuntimeConfig): Promise<void> {
|
||||
if (!config.endpoint || !config.apiKey) {
|
||||
throw new Error("Splunk endpoint and HEC token are required");
|
||||
}
|
||||
const response = await logProviderFetch(
|
||||
`${normalizeEndpointUrl(config.endpoint).replace(/\/$/, "")}/services/collector/health`,
|
||||
{ headers: { Authorization: `Splunk ${config.apiKey}` } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Splunk HEC health check failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
269
packages/server/src/services/log-management/service.ts
Normal file
269
packages/server/src/services/log-management/service.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateLogProvider,
|
||||
logProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { getLogProviderAdapter } from "./providers/registry";
|
||||
import type { LogProviderRuntimeConfig, LogProviderType } from "./types";
|
||||
|
||||
export type LogProvider = typeof logProvider.$inferSelect;
|
||||
|
||||
const CREDENTIAL_COLUMNS = {
|
||||
endpoint: false,
|
||||
apiKey: false,
|
||||
apiSecret: false,
|
||||
} as const;
|
||||
|
||||
const assertRequiredCredentialFields = (
|
||||
providerType: LogProviderType,
|
||||
values: {
|
||||
endpoint?: string | null;
|
||||
apiKey?: string | null;
|
||||
apiSecret?: string | null;
|
||||
extraConfig?: Record<string, unknown> | null;
|
||||
},
|
||||
) => {
|
||||
const adapter = getLogProviderAdapter(providerType);
|
||||
const fieldValues: Record<string, unknown> = {
|
||||
...(values.extraConfig ?? {}),
|
||||
endpoint: values.endpoint,
|
||||
apiKey: values.apiKey,
|
||||
apiSecret: values.apiSecret,
|
||||
};
|
||||
const missing = adapter.credentialFields
|
||||
.filter((field) => field.required)
|
||||
.filter((field) => {
|
||||
const value = fieldValues[field.key];
|
||||
return value === undefined || value === null || value === "";
|
||||
})
|
||||
.map((field) => field.label);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Missing required field(s) for ${adapter.label}: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (adapter.validateConfig) {
|
||||
try {
|
||||
adapter.validateConfig({
|
||||
logProviderId: "validate",
|
||||
name: "",
|
||||
endpoint: values.endpoint ?? null,
|
||||
apiKey: values.apiKey ?? null,
|
||||
apiSecret: values.apiSecret ?? null,
|
||||
extraConfig: values.extraConfig ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Invalid configuration",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createLogProvider = async (
|
||||
input: z.infer<typeof apiCreateLogProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
assertRequiredCredentialFields(input.providerType, input);
|
||||
|
||||
const created = await db
|
||||
.insert(logProvider)
|
||||
.values({
|
||||
...input,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!created) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating log provider",
|
||||
});
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
export const updateLogProvider = async (
|
||||
logProviderId: string,
|
||||
data: Partial<z.infer<typeof apiCreateLogProvider>>,
|
||||
) => {
|
||||
const existing = await findLogProviderByIdWithCredentials(logProviderId);
|
||||
const isChangingType =
|
||||
data.providerType !== undefined &&
|
||||
data.providerType !== existing.providerType;
|
||||
const dataToApply = isChangingType
|
||||
? {
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
...data,
|
||||
}
|
||||
: data.extraConfig != null
|
||||
? {
|
||||
...data,
|
||||
extraConfig: { ...(existing.extraConfig ?? {}), ...data.extraConfig },
|
||||
}
|
||||
: data;
|
||||
const merged = { ...existing, ...dataToApply };
|
||||
assertRequiredCredentialFields(merged.providerType, merged);
|
||||
|
||||
const updated = await db
|
||||
.update(logProvider)
|
||||
.set(dataToApply)
|
||||
.where(eq(logProvider.logProviderId, logProviderId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Log provider not found",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const removeLogProvider = async (logProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(logProvider)
|
||||
.where(eq(logProvider.logProviderId, logProviderId))
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Log provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const findLogProviderById = async (logProviderId: string) => {
|
||||
const found = await db.query.logProvider.findFirst({
|
||||
where: eq(logProvider.logProviderId, logProviderId),
|
||||
columns: CREDENTIAL_COLUMNS,
|
||||
});
|
||||
if (!found) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Log provider not found",
|
||||
});
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
export const findLogProviderByIdWithCredentials = async (
|
||||
logProviderId: string,
|
||||
) => {
|
||||
const found = await db.query.logProvider.findFirst({
|
||||
where: eq(logProvider.logProviderId, logProviderId),
|
||||
});
|
||||
if (!found) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Log provider not found",
|
||||
});
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
export const findLogProvidersByOrganization = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.logProvider.findMany({
|
||||
where: eq(logProvider.organizationId, organizationId),
|
||||
columns: CREDENTIAL_COLUMNS,
|
||||
});
|
||||
};
|
||||
|
||||
export const findEnabledLogProvidersByOrganization = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.logProvider.findMany({
|
||||
where: and(
|
||||
eq(logProvider.organizationId, organizationId),
|
||||
eq(logProvider.enabled, true),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const hasEnabledLogProvider = async (organizationId: string) => {
|
||||
const found = await db.query.logProvider.findFirst({
|
||||
where: and(
|
||||
eq(logProvider.organizationId, organizationId),
|
||||
eq(logProvider.enabled, true),
|
||||
),
|
||||
columns: { logProviderId: true },
|
||||
});
|
||||
return !!found;
|
||||
};
|
||||
|
||||
export const toRuntimeConfig = (
|
||||
provider: LogProvider,
|
||||
): LogProviderRuntimeConfig => ({
|
||||
logProviderId: provider.logProviderId,
|
||||
name: provider.name,
|
||||
endpoint: provider.endpoint,
|
||||
apiKey: provider.apiKey,
|
||||
apiSecret: provider.apiSecret,
|
||||
extraConfig: provider.extraConfig,
|
||||
});
|
||||
|
||||
export const testLogProviderConnection = async (
|
||||
params:
|
||||
| { logProviderId: string }
|
||||
| { providerType: LogProviderType; config: LogProviderRuntimeConfig },
|
||||
) => {
|
||||
let providerType: LogProviderType;
|
||||
let runtimeConfig: LogProviderRuntimeConfig;
|
||||
|
||||
if ("logProviderId" in params) {
|
||||
const provider = await findLogProviderByIdWithCredentials(
|
||||
params.logProviderId,
|
||||
);
|
||||
providerType = provider.providerType;
|
||||
runtimeConfig = toRuntimeConfig(provider);
|
||||
} else {
|
||||
providerType = params.providerType;
|
||||
runtimeConfig = params.config;
|
||||
}
|
||||
|
||||
const adapter = getLogProviderAdapter(providerType);
|
||||
if (!adapter.testConnection) {
|
||||
return {
|
||||
success: true,
|
||||
warning: "This provider does not support connection testing",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await adapter.testConnection(runtimeConfig);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Connection test failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const sanitizeLogProvider = <
|
||||
T extends { endpoint?: unknown; apiKey?: unknown; apiSecret?: unknown },
|
||||
>(
|
||||
provider: T,
|
||||
) => {
|
||||
const { endpoint, apiKey, apiSecret, ...rest } = provider;
|
||||
return rest;
|
||||
};
|
||||
124
packages/server/src/services/log-management/types.ts
Normal file
124
packages/server/src/services/log-management/types.ts
Normal file
@ -0,0 +1,124 @@
|
||||
export const LOG_PROVIDER_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
const isMetadataAddress = (address: string): boolean => {
|
||||
const lower = address.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (lower === "fd00:ec2::254") return true;
|
||||
if (lower.startsWith("169.254.")) return true;
|
||||
if (/^fe[89ab]/.test(lower)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const METADATA_HOSTNAMES = new Set([
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
]);
|
||||
|
||||
const assertNotMetadataEndpoint = async (rawUrl: string): Promise<void> => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const blocked = () =>
|
||||
new Error(
|
||||
"This endpoint resolves to a cloud metadata address and can't be used here.",
|
||||
);
|
||||
if (METADATA_HOSTNAMES.has(url.hostname.toLowerCase())) {
|
||||
throw blocked();
|
||||
}
|
||||
if (isMetadataAddress(url.hostname)) {
|
||||
throw blocked();
|
||||
}
|
||||
try {
|
||||
const { lookup } = await import("node:dns/promises");
|
||||
const results = await lookup(url.hostname, { all: true });
|
||||
if (results.some((r) => isMetadataAddress(r.address))) {
|
||||
throw blocked();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("metadata")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const logProviderFetch = async (url: string, init: RequestInit = {}) => {
|
||||
await assertNotMetadataEndpoint(url);
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(LOG_PROVIDER_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
|
||||
export interface LogProviderCredentialField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "password" | "url";
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export interface VectorTransformConfig {
|
||||
type: string;
|
||||
inputs: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VectorSinkConfig {
|
||||
type: string;
|
||||
inputs: string[];
|
||||
buffer: {
|
||||
type: "disk" | "memory";
|
||||
max_size: number;
|
||||
when_full?: "block" | "drop_newest";
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LogProviderRuntimeConfig {
|
||||
logProviderId: string;
|
||||
name: string;
|
||||
endpoint: string | null;
|
||||
apiKey: string | null;
|
||||
apiSecret: string | null;
|
||||
extraConfig: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type LogProviderType =
|
||||
| "loki"
|
||||
| "datadog"
|
||||
| "betterstack"
|
||||
| "elasticsearch"
|
||||
| "splunk_hec"
|
||||
| "aws_cloudwatch";
|
||||
|
||||
export const DEFAULT_DISK_BUFFER = {
|
||||
type: "disk" as const,
|
||||
max_size: 268_435_488,
|
||||
when_full: "block" as const,
|
||||
};
|
||||
|
||||
export const normalizeEndpointUrl = (endpoint: string): string =>
|
||||
!endpoint || /^https?:\/\//.test(endpoint) ? endpoint : `https://${endpoint}`;
|
||||
|
||||
export interface LogProviderAdapter {
|
||||
type: LogProviderType;
|
||||
label: string;
|
||||
docsUrl?: string;
|
||||
credentialFields: LogProviderCredentialField[];
|
||||
toVectorTransform?(
|
||||
config: LogProviderRuntimeConfig,
|
||||
transformId: string,
|
||||
scopeTransformId: string,
|
||||
): VectorTransformConfig;
|
||||
toVectorSink(
|
||||
config: LogProviderRuntimeConfig,
|
||||
sinkId: string,
|
||||
inputId: string,
|
||||
): VectorSinkConfig;
|
||||
testConnection?(config: LogProviderRuntimeConfig): Promise<void>;
|
||||
validateConfig?(config: LogProviderRuntimeConfig): void;
|
||||
}
|
||||
@ -87,6 +87,25 @@ export const findServersByUserId = async (userId: string) => {
|
||||
return servers;
|
||||
};
|
||||
|
||||
export const findServersWithLogManagementEnabled = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.server.findMany({
|
||||
where: and(
|
||||
eq(server.organizationId, organizationId),
|
||||
eq(server.enableLogManagement, true),
|
||||
),
|
||||
columns: { serverId: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const findAllServersWithLogManagementEnabled = async () => {
|
||||
return await db.query.server.findMany({
|
||||
where: eq(server.enableLogManagement, true),
|
||||
columns: { serverId: true, organizationId: true },
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteServer = async (serverId: string) => {
|
||||
const currentServer = await db
|
||||
.delete(server)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { webServerSettings } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get the web server settings (singleton - only one row should exist)
|
||||
@ -42,3 +42,33 @@ export const updateWebServerSettings = async (
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const claimWebServerLogManagement = async (
|
||||
organizationId: string,
|
||||
enableLogManagement: boolean,
|
||||
) => {
|
||||
const current = await getWebServerSettings();
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(webServerSettings)
|
||||
.set({
|
||||
enableLogManagement,
|
||||
logManagementOrganizationId: enableLogManagement ? organizationId : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(webServerSettings.id, current.id),
|
||||
or(
|
||||
isNull(webServerSettings.logManagementOrganizationId),
|
||||
eq(webServerSettings.logManagementOrganizationId, organizationId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated ?? null;
|
||||
};
|
||||
|
||||
656
packages/server/src/setup/vector-setup.ts
Normal file
656
packages/server/src/setup/vector-setup.ts
Normal file
@ -0,0 +1,656 @@
|
||||
import {
|
||||
chmodSync as fsChmodSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { dbUrl } from "@dokploy/server/db/constants";
|
||||
import { projects } from "@dokploy/server/db/schema";
|
||||
import {
|
||||
findEnabledLogProvidersByOrganization,
|
||||
hasEnabledLogProvider,
|
||||
type LogProvider,
|
||||
toRuntimeConfig,
|
||||
} from "@dokploy/server/services/log-management/service";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||
import { pullImage, pullRemoteImage } from "@dokploy/server/utils/docker/utils";
|
||||
import { ExecError } from "@dokploy/server/utils/process/ExecError";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
writeFileRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { getRemoteDocker } from "@dokploy/server/utils/servers/remote-docker";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
import { stringify } from "yaml";
|
||||
import { getLogProviderAdapter } from "../services/log-management/providers/registry";
|
||||
import type {
|
||||
VectorSinkConfig,
|
||||
VectorTransformConfig,
|
||||
} from "../services/log-management/types";
|
||||
|
||||
const VECTOR_IMAGE = "timberio/vector:latest-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";
|
||||
|
||||
interface AppNameLookupEntry {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
environmentId: string;
|
||||
environmentName: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
}
|
||||
|
||||
const escapeVrlString = (value: string): string =>
|
||||
value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: replacing them is the point
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ");
|
||||
|
||||
const buildAppNameLookup = async (
|
||||
organizationId: string,
|
||||
): Promise<Record<string, AppNameLookupEntry>> => {
|
||||
const projectRows = await db.query.projects.findMany({
|
||||
where: eq(projects.organizationId, organizationId),
|
||||
columns: { projectId: true, name: true },
|
||||
with: {
|
||||
environments: {
|
||||
columns: { environmentId: true, name: true },
|
||||
with: {
|
||||
applications: {
|
||||
columns: { applicationId: true, appName: true, name: true },
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lookup: Record<string, AppNameLookupEntry> = {};
|
||||
for (const project of projectRows) {
|
||||
for (const environment of project.environments) {
|
||||
for (const application of environment.applications) {
|
||||
lookup[application.appName] = {
|
||||
projectId: project.projectId,
|
||||
projectName: project.name,
|
||||
environmentId: environment.environmentId,
|
||||
environmentName: environment.name,
|
||||
applicationId: application.applicationId,
|
||||
applicationName: application.name,
|
||||
};
|
||||
}
|
||||
for (const composeService of environment.compose) {
|
||||
lookup[composeService.appName] = {
|
||||
projectId: project.projectId,
|
||||
projectName: project.name,
|
||||
environmentId: environment.environmentId,
|
||||
environmentName: environment.name,
|
||||
applicationId: composeService.composeId,
|
||||
applicationName: composeService.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
};
|
||||
|
||||
const buildScopeTransformSource = (
|
||||
organizationId: string,
|
||||
lookup: Record<string, AppNameLookupEntry>,
|
||||
): string => {
|
||||
const lines: string[] = [
|
||||
`.dokploy_organization = "${escapeVrlString(organizationId)}"`,
|
||||
'.dokploy_project = ""',
|
||||
'.dokploy_project_id = ""',
|
||||
'.dokploy_environment = ""',
|
||||
'.dokploy_environment_id = ""',
|
||||
'.dokploy_application = ""',
|
||||
'.dokploy_application_id = ""',
|
||||
];
|
||||
|
||||
const entries = Object.entries(lookup);
|
||||
if (entries.length === 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
lines.push('app_name = .label."com.docker.compose.project"');
|
||||
lines.push("if app_name == null {");
|
||||
lines.push(' app_name = .label."com.docker.stack.namespace"');
|
||||
lines.push("}");
|
||||
lines.push("if app_name == null {");
|
||||
lines.push(' app_name = .label."com.docker.swarm.service.name"');
|
||||
lines.push("}");
|
||||
lines.push("if app_name != null {");
|
||||
entries.forEach(([appName, entry], index) => {
|
||||
const branch = index === 0 ? " if" : " } else if";
|
||||
lines.push(`${branch} app_name == "${escapeVrlString(appName)}" {`);
|
||||
lines.push(
|
||||
` .dokploy_project = "${escapeVrlString(entry.projectName)}"`,
|
||||
);
|
||||
lines.push(
|
||||
` .dokploy_project_id = "${escapeVrlString(entry.projectId)}"`,
|
||||
);
|
||||
lines.push(
|
||||
` .dokploy_environment = "${escapeVrlString(entry.environmentName)}"`,
|
||||
);
|
||||
lines.push(
|
||||
` .dokploy_environment_id = "${escapeVrlString(entry.environmentId)}"`,
|
||||
);
|
||||
lines.push(
|
||||
` .dokploy_application = "${escapeVrlString(entry.applicationName)}"`,
|
||||
);
|
||||
lines.push(
|
||||
` .dokploy_application_id = "${escapeVrlString(entry.applicationId)}"`,
|
||||
);
|
||||
});
|
||||
lines.push(" }");
|
||||
lines.push("}");
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const buildSinksAndTransforms = (
|
||||
providers: Array<LogProvider>,
|
||||
baseTransformId: string,
|
||||
): {
|
||||
sinks: Record<string, VectorSinkConfig>;
|
||||
transforms: Record<string, VectorTransformConfig>;
|
||||
} => {
|
||||
const sinks: Record<string, VectorSinkConfig> = {};
|
||||
const transforms: Record<string, VectorTransformConfig> = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const adapter = getLogProviderAdapter(provider.providerType);
|
||||
const runtimeConfig = toRuntimeConfig(provider);
|
||||
const sinkId = `sink_${provider.logProviderId}`;
|
||||
let inputId = baseTransformId;
|
||||
let transform: { id: string; config: VectorTransformConfig } | null =
|
||||
null;
|
||||
if (adapter.toVectorTransform) {
|
||||
const transformId = `transform_${provider.logProviderId}`;
|
||||
transform = {
|
||||
id: transformId,
|
||||
config: adapter.toVectorTransform(
|
||||
runtimeConfig,
|
||||
transformId,
|
||||
baseTransformId,
|
||||
),
|
||||
};
|
||||
inputId = transformId;
|
||||
}
|
||||
const sink = adapter.toVectorSink(runtimeConfig, sinkId, inputId);
|
||||
if (transform) {
|
||||
transforms[transform.id] = transform.config;
|
||||
}
|
||||
sinks[sinkId] = sink;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Vector] Skipping log provider "${provider.name}" (${provider.logProviderId}) — invalid config:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { sinks, transforms };
|
||||
};
|
||||
|
||||
export interface VectorOrgData {
|
||||
providers: Array<LogProvider>;
|
||||
lookup: Record<string, AppNameLookupEntry>;
|
||||
}
|
||||
|
||||
export const loadVectorOrgData = async (
|
||||
organizationId: string,
|
||||
): Promise<VectorOrgData> => {
|
||||
const [providers, lookup] = await Promise.all([
|
||||
findEnabledLogProvidersByOrganization(organizationId),
|
||||
buildAppNameLookup(organizationId),
|
||||
]);
|
||||
return { providers, lookup };
|
||||
};
|
||||
|
||||
export const buildVectorConfigYaml = async (
|
||||
organizationId: string,
|
||||
preloaded?: VectorOrgData,
|
||||
options?: { dropUnmatched?: boolean },
|
||||
) => {
|
||||
const { providers, lookup } =
|
||||
preloaded ?? (await loadVectorOrgData(organizationId));
|
||||
const baseTransformId = options?.dropUnmatched
|
||||
? "dokploy_scope_local_only"
|
||||
: "dokploy_scope";
|
||||
const { sinks, transforms } = buildSinksAndTransforms(
|
||||
providers,
|
||||
baseTransformId,
|
||||
);
|
||||
|
||||
const config = {
|
||||
data_dir: VECTOR_DATA_DIR_CONTAINER,
|
||||
sources: {
|
||||
docker_logs_source: {
|
||||
type: "docker_logs",
|
||||
docker_host: "unix:///var/run/docker.sock",
|
||||
},
|
||||
},
|
||||
transforms: {
|
||||
dokploy_scope: {
|
||||
type: "remap",
|
||||
inputs: ["docker_logs_source"],
|
||||
source: buildScopeTransformSource(organizationId, lookup),
|
||||
},
|
||||
...(options?.dropUnmatched
|
||||
? {
|
||||
dokploy_scope_local_only: {
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_project != ""',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...transforms,
|
||||
},
|
||||
sinks,
|
||||
};
|
||||
|
||||
return stringify(config);
|
||||
};
|
||||
|
||||
export const collectSecretValues = (
|
||||
providers: Array<LogProvider>,
|
||||
): 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);
|
||||
for (const value of Object.values(provider.extraConfig ?? {})) {
|
||||
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 VECTOR_CONFIG_LOCK_NAMESPACE = 87_234_501;
|
||||
|
||||
const hashServerIdToInt32 = (serverId: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < serverId.length; i++) {
|
||||
hash = (Math.imul(31, hash) + serverId.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
let lockPool: ReturnType<typeof postgres> | undefined;
|
||||
const getLockPool = () => {
|
||||
if (!lockPool) {
|
||||
lockPool = postgres(dbUrl, { max: 10 });
|
||||
}
|
||||
return lockPool;
|
||||
};
|
||||
|
||||
const LOCK_RETRY_ATTEMPTS = 3;
|
||||
const LOCK_RETRY_DELAY_MS = 1000;
|
||||
|
||||
const configWriteLocks = new Map<string, Promise<void>>();
|
||||
|
||||
export const withConfigWriteLock = async <T>(
|
||||
serverId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const previous = configWriteLocks.get(serverId) ?? Promise.resolve();
|
||||
let release: () => void = () => {};
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
configWriteLocks.set(
|
||||
serverId,
|
||||
previous.then(() => current),
|
||||
);
|
||||
await previous;
|
||||
try {
|
||||
return await runWithCrossProcessLock(serverId, fn);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
const runWithCrossProcessLock = async <T>(
|
||||
serverId: string,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const key1 = VECTOR_CONFIG_LOCK_NAMESPACE;
|
||||
const key2 = hashServerIdToInt32(serverId);
|
||||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
const reserved = await getLockPool().reserve();
|
||||
try {
|
||||
const rows =
|
||||
await reserved`select pg_try_advisory_lock(${key1}, ${key2}) as locked`;
|
||||
if (rows[0]?.locked) {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await reserved`select pg_advisory_unlock(${key1}, ${key2})`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reserved.release();
|
||||
}
|
||||
if (attempt < LOCK_RETRY_ATTEMPTS) {
|
||||
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Vector config sync for server ${serverId} is already in progress on another Dokploy instance, skipped`,
|
||||
);
|
||||
};
|
||||
|
||||
export const syncVectorConfig = async ({
|
||||
serverId,
|
||||
preloaded,
|
||||
}: {
|
||||
serverId: string;
|
||||
preloaded?: VectorOrgData;
|
||||
}) =>
|
||||
withConfigWriteLock(serverId, async () => {
|
||||
const server = await findServerById(serverId);
|
||||
const { VECTOR_PATH } = paths(true);
|
||||
const configPath = `${VECTOR_PATH}/vector.yaml`;
|
||||
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate`;
|
||||
const orgData =
|
||||
preloaded ?? (await loadVectorOrgData(server.organizationId));
|
||||
const yamlStr = await buildVectorConfigYaml(server.organizationId, orgData);
|
||||
|
||||
await execAsyncRemote(serverId, `mkdir -p ${VECTOR_PATH}/data`);
|
||||
await writeFileRemote(serverId, candidatePath, yamlStr);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`chmod 600 ${candidatePath} && chmod 700 ${VECTOR_PATH} && chmod 700 ${VECTOR_PATH}/data`,
|
||||
);
|
||||
try {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${candidatePath}:/etc/vector/vector.yaml:ro ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof ExecError
|
||||
? error.stderr || error.stdout || error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const sanitizedDetail = redactSecrets(
|
||||
detail,
|
||||
collectSecretValues(orgData.providers),
|
||||
);
|
||||
await execAsyncRemote(serverId, `rm -f ${candidatePath}`).catch(
|
||||
(cleanupError) => {
|
||||
console.error(
|
||||
`[Vector] Failed to remove invalid config candidate ${candidatePath} on server ${serverId}:`,
|
||||
cleanupError,
|
||||
);
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
|
||||
);
|
||||
}
|
||||
await execAsyncRemote(serverId, `mv ${candidatePath} ${configPath}`);
|
||||
});
|
||||
|
||||
export const buildServiceSettings = (
|
||||
vectorPath: string,
|
||||
): CreateServiceOptions => {
|
||||
const VECTOR_PATH = vectorPath;
|
||||
return {
|
||||
Name: VECTOR_SERVICE_NAME,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: VECTOR_IMAGE,
|
||||
Args: ["--config", VECTOR_CONFIG_CONTAINER_PATH, "--watch-config"],
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
Networks: [{ Target: "host" }],
|
||||
},
|
||||
Mode: {
|
||||
Replicated: {
|
||||
Replicas: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const stripImageDigest = (image: string): string =>
|
||||
image.split("@")[0] ?? image;
|
||||
|
||||
export const vectorServiceSpecUnchanged = (
|
||||
inspect: { Spec: { TaskTemplate: any; Mode?: unknown } },
|
||||
settings: CreateServiceOptions,
|
||||
): boolean => {
|
||||
const desiredTaskTemplate = settings.TaskTemplate as any;
|
||||
const existing = inspect.Spec.TaskTemplate?.ContainerSpec ?? {};
|
||||
const desired = desiredTaskTemplate?.ContainerSpec ?? {};
|
||||
return (
|
||||
stripImageDigest(existing.Image ?? "") ===
|
||||
stripImageDigest(desired.Image ?? "") &&
|
||||
JSON.stringify(existing.Args ?? []) ===
|
||||
JSON.stringify(desired.Args ?? []) &&
|
||||
JSON.stringify(existing.Mounts ?? []) ===
|
||||
JSON.stringify(desired.Mounts ?? []) &&
|
||||
JSON.stringify(inspect.Spec.Mode ?? {}) ===
|
||||
JSON.stringify(settings.Mode ?? {})
|
||||
);
|
||||
};
|
||||
|
||||
const deployVectorService = async (
|
||||
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
|
||||
settings: CreateServiceOptions,
|
||||
) => {
|
||||
try {
|
||||
const service = docker.getService(VECTOR_SERVICE_NAME);
|
||||
const inspect = await service.inspect();
|
||||
if (vectorServiceSpecUnchanged(inspect, settings)) {
|
||||
return;
|
||||
}
|
||||
await service.update({
|
||||
version: Number.parseInt(inspect.Version.Index, 10),
|
||||
...settings,
|
||||
TaskTemplate: {
|
||||
...settings.TaskTemplate,
|
||||
ForceUpdate: (inspect.Spec.TaskTemplate.ForceUpdate ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await docker.createService(settings);
|
||||
}
|
||||
};
|
||||
|
||||
export const setupVectorAgent = async ({
|
||||
serverId,
|
||||
preloaded,
|
||||
}: {
|
||||
serverId: string;
|
||||
preloaded?: VectorOrgData;
|
||||
}) => {
|
||||
await syncVectorConfig({ serverId, preloaded });
|
||||
const { VECTOR_PATH } = paths(true);
|
||||
|
||||
await pullRemoteImage(VECTOR_IMAGE, serverId);
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
await deployVectorService(docker, buildServiceSettings(VECTOR_PATH));
|
||||
};
|
||||
|
||||
export const removeVectorAgent = async ({ serverId }: { serverId: string }) => {
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) {
|
||||
throw new Error(`No SSH key configured for server ${serverId}`);
|
||||
}
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
try {
|
||||
await docker.getService(VECTOR_SERVICE_NAME).remove();
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { VECTOR_PATH } = paths(true);
|
||||
await execAsyncRemote(serverId, `rm -rf ${VECTOR_PATH}`);
|
||||
};
|
||||
|
||||
export const syncVectorAgent = async ({
|
||||
serverId,
|
||||
preloaded,
|
||||
}: {
|
||||
serverId: string;
|
||||
preloaded?: VectorOrgData;
|
||||
}): Promise<{ installed: boolean }> => {
|
||||
const server = await findServerById(serverId);
|
||||
const hasProvider = preloaded
|
||||
? preloaded.providers.length > 0
|
||||
: await hasEnabledLogProvider(server.organizationId);
|
||||
const shouldRun = server.enableLogManagement && hasProvider;
|
||||
|
||||
if (shouldRun) {
|
||||
await setupVectorAgent({ serverId, preloaded });
|
||||
} else {
|
||||
await removeVectorAgent({ serverId });
|
||||
}
|
||||
|
||||
return { installed: shouldRun };
|
||||
};
|
||||
|
||||
export const syncWebVectorConfig = async (
|
||||
organizationId: string,
|
||||
preloaded?: VectorOrgData,
|
||||
) =>
|
||||
withConfigWriteLock("web", async () => {
|
||||
const { VECTOR_PATH } = paths();
|
||||
const configPath = `${VECTOR_PATH}/vector.yaml`;
|
||||
const candidatePath = `${VECTOR_PATH}/vector.yaml.candidate`;
|
||||
const orgData = preloaded ?? (await loadVectorOrgData(organizationId));
|
||||
const yamlStr = await buildVectorConfigYaml(organizationId, orgData, {
|
||||
dropUnmatched: true,
|
||||
});
|
||||
|
||||
mkdirSync(`${VECTOR_PATH}/data`, { recursive: true });
|
||||
writeFileSync(candidatePath, yamlStr, "utf8");
|
||||
fsChmodSync(candidatePath, 0o600);
|
||||
fsChmodSync(VECTOR_PATH, 0o700);
|
||||
fsChmodSync(`${VECTOR_PATH}/data`, 0o700);
|
||||
try {
|
||||
await execAsync(
|
||||
`docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro -v ${candidatePath}:/etc/vector/vector.yaml:ro ${VECTOR_IMAGE} validate --skip-healthchecks /etc/vector/vector.yaml`,
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof ExecError
|
||||
? error.stderr || error.stdout || error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
const sanitizedDetail = redactSecrets(
|
||||
detail,
|
||||
collectSecretValues(orgData.providers),
|
||||
);
|
||||
try {
|
||||
rmSync(candidatePath, { force: true });
|
||||
} catch (cleanupError) {
|
||||
console.error(
|
||||
`[Vector] Failed to remove invalid local config candidate ${candidatePath}:`,
|
||||
cleanupError,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Generated Vector config failed validation, not applying it: ${sanitizedDetail}`,
|
||||
);
|
||||
}
|
||||
renameSync(candidatePath, configPath);
|
||||
});
|
||||
|
||||
export const setupWebVectorAgent = async (
|
||||
organizationId: string,
|
||||
preloaded?: VectorOrgData,
|
||||
) => {
|
||||
await syncWebVectorConfig(organizationId, preloaded);
|
||||
const { VECTOR_PATH } = paths();
|
||||
|
||||
await pullImage(VECTOR_IMAGE);
|
||||
const docker = await getRemoteDocker();
|
||||
await deployVectorService(docker, buildServiceSettings(VECTOR_PATH));
|
||||
};
|
||||
|
||||
export const removeWebVectorAgent = async () => {
|
||||
const docker = await getRemoteDocker();
|
||||
try {
|
||||
await docker.getService(VECTOR_SERVICE_NAME).remove();
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const { VECTOR_PATH } = paths();
|
||||
rmSync(VECTOR_PATH, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
export const syncWebVectorAgent = async (
|
||||
preloaded?: VectorOrgData,
|
||||
): Promise<{ installed: boolean }> => {
|
||||
const settings = await getWebServerSettings();
|
||||
const organizationId = settings?.logManagementOrganizationId ?? null;
|
||||
const hasProvider = organizationId
|
||||
? preloaded
|
||||
? preloaded.providers.length > 0
|
||||
: await hasEnabledLogProvider(organizationId)
|
||||
: false;
|
||||
const shouldRun =
|
||||
!!settings?.enableLogManagement && !!organizationId && hasProvider;
|
||||
|
||||
if (shouldRun && organizationId) {
|
||||
await setupWebVectorAgent(organizationId, preloaded);
|
||||
} else {
|
||||
await removeWebVectorAgent();
|
||||
}
|
||||
|
||||
return { installed: shouldRun };
|
||||
};
|
||||
246
pnpm-lock.yaml
246
pnpm-lock.yaml
@ -117,6 +117,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@aws-sdk/client-cloudwatch-logs':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1127.0
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1108.0
|
||||
@ -596,6 +599,9 @@ importers:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.30
|
||||
version: 2.0.30(zod@4.3.6)
|
||||
'@aws-sdk/client-cloudwatch-logs':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1127.0
|
||||
'@aws-sdk/client-route-53':
|
||||
specifier: ^3.1108.0
|
||||
version: 3.1108.0
|
||||
@ -894,6 +900,10 @@ packages:
|
||||
resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@aws-sdk/client-cloudwatch-logs@3.1127.0':
|
||||
resolution: {integrity: sha512-BhRRVPLSEAQ3Yep8izRyVaMfHk1inNHm1C4txBWoJQYDVE/EX/SAIxtnpaF9KZYZzgBn8l7CBXC616435E2vlw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-route-53@3.1108.0':
|
||||
resolution: {integrity: sha512-fbHGq25KlbqSIEQC3T+3ghsXmnpknjU3VxMgTOmqbKAqTLVB0ZLhxzPOxKuJFTd5m5mzSh9lCAZR5Nnlg+V9kQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -910,38 +920,74 @@ packages:
|
||||
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.977.9':
|
||||
resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.68':
|
||||
resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.70':
|
||||
resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.70':
|
||||
resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.72':
|
||||
resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.13':
|
||||
resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.15':
|
||||
resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.75':
|
||||
resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.77':
|
||||
resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.79':
|
||||
resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.82':
|
||||
resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.68':
|
||||
resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.70':
|
||||
resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.12':
|
||||
resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.14':
|
||||
resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.74':
|
||||
resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
||||
resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.24':
|
||||
resolution: {integrity: sha512-+z3OqGhqLA46vF5KbnjuygzSQo9A2cWsbuDy1rdrbWSPDCNT5CCRC+veIxRnqvxwpj/BY8P8XNz+WwCmX0e8rg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@ -950,22 +996,42 @@ packages:
|
||||
resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.44':
|
||||
resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.46':
|
||||
resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1108.0':
|
||||
resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1116.0':
|
||||
resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.3':
|
||||
resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.974.5':
|
||||
resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.38':
|
||||
resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.40':
|
||||
resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0':
|
||||
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3944,6 +4010,10 @@ packages:
|
||||
resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/core@3.33.3':
|
||||
resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.0':
|
||||
resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3952,10 +4022,18 @@ packages:
|
||||
resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.8.0':
|
||||
resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.10.0':
|
||||
resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.12.1':
|
||||
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.7.0':
|
||||
resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -3964,6 +4042,10 @@ packages:
|
||||
resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.18.0':
|
||||
resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
@ -9315,6 +9397,17 @@ snapshots:
|
||||
escape-html: 1.0.3
|
||||
xpath: 0.0.32
|
||||
|
||||
'@aws-sdk/client-cloudwatch-logs@3.1127.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/credential-provider-node': 3.972.82
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-route-53@3.1108.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9360,6 +9453,17 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/core@3.977.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@aws-sdk/xml-builder': 3.972.40
|
||||
'@aws/lambda-invoke-store': 0.3.0
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.18.0
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.68':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9368,6 +9472,14 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9378,6 +9490,16 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.72':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.13':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9394,6 +9516,22 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.973.15':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/credential-provider-env': 3.972.70
|
||||
'@aws-sdk/credential-provider-http': 3.972.72
|
||||
'@aws-sdk/credential-provider-login': 3.972.77
|
||||
'@aws-sdk/credential-provider-process': 3.972.70
|
||||
'@aws-sdk/credential-provider-sso': 3.973.14
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.76
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.75':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9403,6 +9541,15 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.77':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.79':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.68
|
||||
@ -9417,6 +9564,20 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.82':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.70
|
||||
'@aws-sdk/credential-provider-http': 3.972.72
|
||||
'@aws-sdk/credential-provider-ini': 3.973.15
|
||||
'@aws-sdk/credential-provider-process': 3.972.70
|
||||
'@aws-sdk/credential-provider-sso': 3.973.14
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.76
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/credential-provider-imds': 4.5.0
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.68':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9425,6 +9586,14 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.70':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.12':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9435,6 +9604,16 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.973.14':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/token-providers': 3.1116.0
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.74':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9444,6 +9623,15 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.76':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-route53@3.972.24':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.3
|
||||
@ -9461,6 +9649,17 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.44':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.46
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.44':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.3
|
||||
@ -9468,6 +9667,13 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.46':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/signature-v4': 5.7.0
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1108.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.7
|
||||
@ -9477,16 +9683,35 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1116.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.9
|
||||
'@aws-sdk/nested-clients': 3.997.44
|
||||
'@aws-sdk/types': 3.974.5
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.3':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/types@3.974.5':
|
||||
dependencies:
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.38':
|
||||
dependencies:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.40':
|
||||
dependencies:
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.3.0': {}
|
||||
|
||||
'@babel/code-frame@7.29.0':
|
||||
@ -12820,6 +13045,11 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/core@3.33.3':
|
||||
dependencies:
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.5.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
@ -12832,12 +13062,24 @@ snapshots:
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.8.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.10.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
'@smithy/types': 4.17.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.12.1':
|
||||
dependencies:
|
||||
'@smithy/core': 3.33.3
|
||||
'@smithy/types': 4.18.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.7.0':
|
||||
dependencies:
|
||||
'@smithy/core': 3.32.0
|
||||
@ -12848,6 +13090,10 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.18.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user