diff --git a/packages/adapters/src/backup/producers/detect.test.ts b/packages/adapters/src/backup/producers/detect.test.ts new file mode 100644 index 000000000..5c0816b94 --- /dev/null +++ b/packages/adapters/src/backup/producers/detect.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import "../index"; +import { autoDetectProducer } from "./detect"; +import type { PayloadKind, ServiceHandle } from "../types"; + +const POSTGRES_ENV = { POSTGRES_DB: "app", POSTGRES_USER: "app", POSTGRES_PASSWORD: "pw" }; +const MYSQL_ENV = { MYSQL_ROOT_PASSWORD: "pw", MYSQL_DATABASE: "app" }; +const MYSQL_ROUTER_ENV = { + MYSQL_HOST: "db", + MYSQL_PORT: "3306", + MYSQL_USER: "app", + MYSQL_PASSWORD: "pw", +}; +const MONGO_ENV = { MONGO_INITDB_ROOT_USERNAME: "root", MONGO_INITDB_ROOT_PASSWORD: "pw" }; +const REDIS_ENV = { REDIS_PASSWORD: "pw" }; + +function serviceWith(image: string, env: Record): ServiceHandle { + return { + id: "svc-1", + projectId: "proj-1", + name: "db", + image, + env, + volumes: [], + containerId: "c1", + projectSlug: "app", + namespaceVolumes: true, + }; +} + +function kindsFor(cases: Array<[string, Record]>): Record { + const out: Record = {}; + for (const [image, env] of cases) out[image] = autoDetectProducer(serviceWith(image, env)).kind; + return out; +} + +describe("autoDetectProducer", () => { + it("routes an untagged database image to its dump producer", () => { + expect( + kindsFor([ + ["postgres", POSTGRES_ENV], + ["postgis/postgis", POSTGRES_ENV], + ["mysql", MYSQL_ENV], + ["mariadb", MYSQL_ENV], + ["percona/percona-server", MYSQL_ENV], + ["mongo", MONGO_ENV], + ["percona/percona-server-mongodb", MONGO_ENV], + ["redis", REDIS_ENV], + ]), + ).toEqual({ + postgres: "pg_dump", + "postgis/postgis": "pg_dump", + mysql: "mysql_dump", + mariadb: "mysql_dump", + "percona/percona-server": "mysql_dump", + mongo: "mongo_dump", + "percona/percona-server-mongodb": "mongo_dump", + redis: "redis_rdb", + }); + }); + + it("routes a tagged database image to its dump producer", () => { + expect( + kindsFor([ + ["postgres:16", POSTGRES_ENV], + ["postgis/postgis:16-3.4", POSTGRES_ENV], + ["mysql:8.0", MYSQL_ENV], + ["mariadb:11", MYSQL_ENV], + ["mongo:7", MONGO_ENV], + ["redis:7", REDIS_ENV], + ["redis/redis-stack:latest", REDIS_ENV], + ]), + ).toEqual({ + "postgres:16": "pg_dump", + "postgis/postgis:16-3.4": "pg_dump", + "mysql:8.0": "mysql_dump", + "mariadb:11": "mysql_dump", + "mongo:7": "mongo_dump", + "redis:7": "redis_rdb", + "redis/redis-stack:latest": "redis_rdb", + }); + }); + + it("leaves an image that merely starts with a database name on the volume fallback", () => { + expect( + kindsFor([ + ["postgrest/postgrest", POSTGRES_ENV], + ["postgresql", POSTGRES_ENV], + ["prometheuscommunity/postgres-exporter", POSTGRES_ENV], + ["myapp/postgres", POSTGRES_ENV], + ["mysqld-exporter", MYSQL_ENV], + ["mongo-express", MONGO_ENV], + ["redis-sentinel", REDIS_ENV], + ["redisinsight", REDIS_ENV], + ]), + ).toEqual({ + "postgrest/postgrest": "volume", + postgresql: "volume", + "prometheuscommunity/postgres-exporter": "volume", + "myapp/postgres": "volume", + "mysqld-exporter": "volume", + "mongo-express": "volume", + "redis-sentinel": "volume", + redisinsight: "volume", + }); + }); + + it("leaves a repository under a database namespace on the volume fallback", () => { + expect( + kindsFor([ + ["mysql/mysql-server:8.0", MYSQL_ENV], + ["mysql/mysql-router:8.0", MYSQL_ROUTER_ENV], + ["mariadb/maxscale:23.08", MYSQL_ROUTER_ENV], + ["postgres/whatever", POSTGRES_ENV], + ["mongo/anything", MONGO_ENV], + ]), + ).toEqual({ + "mysql/mysql-server:8.0": "volume", + "mysql/mysql-router:8.0": "volume", + "mariadb/maxscale:23.08": "volume", + "postgres/whatever": "volume", + "mongo/anything": "volume", + }); + }); + + it("keeps an untagged database image on the volume fallback when its credentials are missing", () => { + expect(autoDetectProducer(serviceWith("postgres", {})).kind).toBe("volume"); + expect(autoDetectProducer(serviceWith("mysql", {})).kind).toBe("volume"); + }); +}); diff --git a/packages/adapters/src/backup/producers/mongo.ts b/packages/adapters/src/backup/producers/mongo.ts index 11c749f0d..013602dbe 100644 --- a/packages/adapters/src/backup/producers/mongo.ts +++ b/packages/adapters/src/backup/producers/mongo.ts @@ -8,7 +8,7 @@ * restore: `mongorestore --archive --gzip --drop` via pipeIntoCommand. * --drop wipes existing collections before importing. * - * Detection: image matches ^(mongo|percona/percona-server-mongodb):.* + * Detection: image matches ^(mongo|percona/percona-server-mongodb)(:|$) */ import { registerProducer } from "../registry"; @@ -22,7 +22,7 @@ import type { ServiceHandle, } from "../types"; -const MONGO_IMAGE_RE = /^(mongo|percona\/percona-server-mongodb):/i; +const MONGO_IMAGE_RE = /^(mongo|percona\/percona-server-mongodb)(:|$)/i; function shellEscape(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; diff --git a/packages/adapters/src/backup/producers/mysql-dump.ts b/packages/adapters/src/backup/producers/mysql-dump.ts index ca7f25949..38c8557b2 100644 --- a/packages/adapters/src/backup/producers/mysql-dump.ts +++ b/packages/adapters/src/backup/producers/mysql-dump.ts @@ -8,7 +8,7 @@ * restore: `zstd -d | mysql` via pipeIntoCommand. mysqldump output is * SQL, so plain mysql client replays it. * - * Detection: image matches ^(mysql|mariadb|percona):.* with + * Detection: image matches ^(mysql|mariadb|percona/percona-server)(:|$) with * MYSQL_ROOT_PASSWORD + (MYSQL_DATABASE OR explicit db) in env. */ @@ -23,7 +23,7 @@ import type { ServiceHandle, } from "../types"; -const MYSQL_IMAGE_RE = /^(mysql|mariadb|percona\/percona-server):/i; +const MYSQL_IMAGE_RE = /^(mysql|mariadb|percona\/percona-server)(:|$)/i; function shellEscape(s: string): string { return `'${s.replace(/'/g, "'\\''")}'`; diff --git a/packages/adapters/src/backup/producers/pg-dump.ts b/packages/adapters/src/backup/producers/pg-dump.ts index aa74ff551..e3ce37506 100644 --- a/packages/adapters/src/backup/producers/pg-dump.ts +++ b/packages/adapters/src/backup/producers/pg-dump.ts @@ -11,7 +11,7 @@ * via executor.pipeIntoCommand. Drops + recreates the schema * atomically inside the dump's transaction (pg_restore --clean). * - * Detection: service.image matches ^(postgres|postgis/postgis):.* and + * Detection: service.image matches ^(postgres|postgis/postgis)(:|$) and * we can read POSTGRES_USER + POSTGRES_DB from the service env. If * either is missing, this producer DOES NOT match — the volume * producer fallback runs instead with a "no postgres creds in env" @@ -30,7 +30,7 @@ import type { ServiceHandle, } from "../types"; -const POSTGRES_IMAGE_RE = /^(postgres|postgis\/postgis):/i; +const POSTGRES_IMAGE_RE = /^(postgres|postgis\/postgis)(:|$)/i; function envOr(service: ServiceHandle, key: string, fallback: string): string { return service.env[key] ?? fallback; diff --git a/packages/adapters/src/backup/producers/redis.ts b/packages/adapters/src/backup/producers/redis.ts index e52ef46e0..8cee7b2cf 100644 --- a/packages/adapters/src/backup/producers/redis.ts +++ b/packages/adapters/src/backup/producers/redis.ts @@ -10,7 +10,7 @@ * producer.restore, so writing dump.rdb while Redis is down * is safe — it'll be loaded on next start.) * - * Detection: image matches ^redis:.* + * Detection: image matches ^redis([:/]|$) * * Caveat: Redis with persistence disabled (AOF-only or no-persistence) * won't have a usable dump.rdb. UI surfaces this in Chunk 4. @@ -27,7 +27,7 @@ import type { ServiceHandle, } from "../types"; -const REDIS_IMAGE_RE = /^redis(\/|:)/i; +const REDIS_IMAGE_RE = /^redis([:/]|$)/i; class RedisRdbProducerImpl implements BackupProducer { readonly kind = "redis_rdb" as const;