From a5c1311646c75b57f88a8d0efd951a12bd36e0f0 Mon Sep 17 00:00:00 2001 From: Dhanraj Date: Sat, 23 May 2026 15:02:38 +0530 Subject: [PATCH] Discover Docker database containers for metrics collection Adds dockerdiscovery package that discovers PostgreSQL, MySQL, and MongoDB containers via the Docker socket. Extracts connection details from container env vars and port mappings. Filters out Coolify's own DB container. MetricsJob now pushes on every beat (not only on credential fetch), so Docker-discovered databases are reported even without control-plane credentials. Push collects from both the primary credential (if available) and any Docker-discovered containers, reporting each as a separate postgresql.database metric set with container identity attributes. Supports supabase/postgres images alongside plain postgres. --- app/jobs/metricsjob/metricsjob.go | 18 +- app/jobs/metricsjob/metricsjob_test.go | 53 +-- app/services/metrics/metrics.go | 120 +++++-- app/services/metrics/metrics_test.go | 27 +- go.mod | 3 +- go.sum | 34 +- internal/dockerdiscovery/discoverer.go | 276 ++++++++++++++++ internal/dockerdiscovery/discoverer_test.go | 347 ++++++++++++++++++++ 8 files changed, 765 insertions(+), 113 deletions(-) create mode 100644 internal/dockerdiscovery/discoverer.go create mode 100644 internal/dockerdiscovery/discoverer_test.go diff --git a/app/jobs/metricsjob/metricsjob.go b/app/jobs/metricsjob/metricsjob.go index dd6e439..021e900 100644 --- a/app/jobs/metricsjob/metricsjob.go +++ b/app/jobs/metricsjob/metricsjob.go @@ -44,6 +44,7 @@ func (mj *MetricsJob) Register(ctx context.Context, mp metrics.Pusher, mcred met ctx, cancel := context.WithCancel(ctx) mj.cancel = cancel var creds []credential.Credential + var lastPgCred credential.Credential var beatCount int mj.wg.Add(1) go func() { @@ -51,7 +52,6 @@ func (mj *MetricsJob) Register(ctx context.Context, mp metrics.Pusher, mcred met mj.config.Trigger(ctx, func() (err error) { beatCount++ - // Determine if we should fetch credentials this beat shouldFetch := len(creds) == 0 || (mj.config.CredFetchInterval > 0 && beatCount%mj.config.CredFetchInterval == 0) @@ -61,26 +61,16 @@ func (mj *MetricsJob) Register(ctx context.Context, mp metrics.Pusher, mcred met return err } - if len(creds) == 0 { - // no op, waiting for the creds to be available - return nil - } - - var pgcred credential.Credential + lastPgCred = credential.Credential{} for _, cred := range creds { - // only support one db for now, first match will exit the finding of creds if cred.Dialect == "postgresql" { - pgcred = cred + lastPgCred = cred break } } - - // Only push when we actually fetch credentials - return mp.Push(pgcred) } - // If we didn't fetch this beat, return nil (no push) - return nil + return mp.Push(lastPgCred) }) }() return cancel diff --git a/app/jobs/metricsjob/metricsjob_test.go b/app/jobs/metricsjob/metricsjob_test.go index 5f46ca8..70f44c9 100644 --- a/app/jobs/metricsjob/metricsjob_test.go +++ b/app/jobs/metricsjob/metricsjob_test.go @@ -72,6 +72,7 @@ func TestRegister_CachesCredsWithinThreshold(t *testing.T) { credFetchInterval int totalBeats int expectedCredCalls int + expectedPushCalls int description string }{ { @@ -79,6 +80,7 @@ func TestRegister_CachesCredsWithinThreshold(t *testing.T) { credFetchInterval: 0, totalBeats: 5, expectedCredCalls: 1, + expectedPushCalls: 5, description: "When passed zero, function called once on beat 1 only", }, { @@ -86,21 +88,24 @@ func TestRegister_CachesCredsWithinThreshold(t *testing.T) { credFetchInterval: 1, totalBeats: 6, expectedCredCalls: 6, + expectedPushCalls: 6, description: "Should call every beat: 1,2,3,4,5,6", }, { name: "SkipCredFetchBeat=2_FetchEverySecondBeat", credFetchInterval: 2, totalBeats: 8, - expectedCredCalls: 5, // Changed from 4 - description: "Should call on beats 1,2,4,6,8", + expectedCredCalls: 5, + expectedPushCalls: 8, + description: "Should push every beat, fetch on beats 1,2,4,6,8", }, { name: "SkipCredFetchBeat=3_FetchEveryThirdBeat", credFetchInterval: 3, totalBeats: 9, - expectedCredCalls: 4, // Changed from 3 - description: "Should call on beats 1,3,6,9", + expectedCredCalls: 4, + expectedPushCalls: 9, + description: "Should push every beat, fetch on beats 1,3,6,9", }, } @@ -135,16 +140,16 @@ func TestRegister_CachesCredsWithinThreshold(t *testing.T) { tt.description, tt.expectedCredCalls, authGetter.calls) } - // Verify Push was called same number of times as GetCreds - if len(pusher.pushCalls) != tt.expectedCredCalls { + // Push is called on every beat regardless of cred fetch + if len(pusher.pushCalls) != tt.expectedPushCalls { t.Errorf("expected %d Push calls, got %d", - tt.expectedCredCalls, len(pusher.pushCalls)) + tt.expectedPushCalls, len(pusher.pushCalls)) } }) } } -func TestRegister_NoPushAfterEmptyCreds(t *testing.T) { +func TestRegister_AlwaysPushesOnEveryBeat(t *testing.T) { tests := []struct { name string credFetchInterval int @@ -158,56 +163,56 @@ func TestRegister_NoPushAfterEmptyCreds(t *testing.T) { credFetchInterval: 0, totalBeats: 5, emptyCredsAfter: 0, - expectedPushCalls: 1, - description: "With interval=0, creds emptied after beat 1, only 1 push (beat 1), no refetch", + expectedPushCalls: 5, + description: "Pushes every beat even after creds emptied", }, { name: "Interval=0_NeverEmpty", credFetchInterval: 0, totalBeats: 5, emptyCredsAfter: -1, - expectedPushCalls: 1, - description: "With interval=0, creds never emptied, only 1 push (beat 1), never refetch", + expectedPushCalls: 5, + description: "Pushes every beat with same creds", }, { name: "Interval=1_EmptyAfterFirstBeat", credFetchInterval: 1, totalBeats: 5, emptyCredsAfter: 0, - expectedPushCalls: 1, - description: "With interval=1, creds emptied after beat 1, only 1 push (beat 1)", + expectedPushCalls: 5, + description: "Pushes every beat even after creds emptied", }, { name: "Interval=2_EmptyAfterFirstBeat", credFetchInterval: 2, totalBeats: 5, emptyCredsAfter: 0, - expectedPushCalls: 1, - description: "With interval=2, creds emptied after beat 1, only 1 push (beat 1)", + expectedPushCalls: 5, + description: "Pushes every beat even after creds emptied", }, { name: "Interval=3_EmptyAfterFirstBeat", credFetchInterval: 3, totalBeats: 6, emptyCredsAfter: 0, - expectedPushCalls: 1, - description: "With interval=3, creds emptied after beat 1, only 1 push (beat 1)", + expectedPushCalls: 6, + description: "Pushes every beat even after creds emptied", }, { name: "Interval=1_EmptyAfterThirdBeat", credFetchInterval: 1, totalBeats: 5, emptyCredsAfter: 2, - expectedPushCalls: 3, - description: "With interval=1, creds emptied after beat 3, pushes on beats 1,2,3", + expectedPushCalls: 5, + description: "Pushes every beat, creds change mid-way", }, { name: "Interval=2_NeverEmpty", credFetchInterval: 2, totalBeats: 8, emptyCredsAfter: -1, - expectedPushCalls: 5, - description: "With interval=2, creds never emptied, pushes on beats 1,2,4,6,8", + expectedPushCalls: 8, + description: "Pushes every beat with same creds", }, } @@ -224,7 +229,6 @@ func TestRegister_NoPushAfterEmptyCreds(t *testing.T) { for i := range tt.totalBeats { _ = fn() if i == tt.emptyCredsAfter { - // After specified beat, all subsequent calls return empty credentials authGetter.creds = []credential.Credential{} } } @@ -266,7 +270,8 @@ func TestRegister_HandlesGetCredsError(t *testing.T) { job.Shutdown() - // Should not push when GetCreds fails + // GetCreds fails on first fetch attempt (shouldFetch=true, len(creds)==0) + // The error is returned, so Push is not called if len(pusher.pushCalls) != 0 { t.Errorf("expected 0 Push calls on error, got %d", len(pusher.pushCalls)) } diff --git a/app/services/metrics/metrics.go b/app/services/metrics/metrics.go index 4e608aa..86b0c6d 100644 --- a/app/services/metrics/metrics.go +++ b/app/services/metrics/metrics.go @@ -14,6 +14,7 @@ import ( domainmetrics "hostlink/domain/metrics" "hostlink/internal/apiserver" "hostlink/internal/crypto" + "hostlink/internal/dockerdiscovery" "hostlink/internal/networkmetrics" "hostlink/internal/pgbouncermetrics" "hostlink/internal/pgmetrics" @@ -30,15 +31,16 @@ type Pusher interface { } type metricspusher struct { - apiserver apiserver.MetricsOperations - agentstate agentstate.Operations - metricscollector pgmetrics.Collector - syscollector sysmetrics.Collector - netcollector networkmetrics.Collector - storagecollector storagemetrics.Collector - pgbouncercollector pgbouncermetrics.Collector - crypto crypto.Service - privateKeyPath string + apiserver apiserver.MetricsOperations + agentstate agentstate.Operations + metricscollector pgmetrics.Collector + syscollector sysmetrics.Collector + netcollector networkmetrics.Collector + storagecollector storagemetrics.Collector + pgbouncercollector pgbouncermetrics.Collector + dockerDiscoverer dockerdiscovery.Discoverer + crypto crypto.Service + privateKeyPath string } func NewWithConf() (*metricspusher, error) { @@ -59,6 +61,7 @@ func NewWithConf() (*metricspusher, error) { netcollector: networkmetrics.New(), storagecollector: storagemetrics.New(), pgbouncercollector: pgbouncermetrics.New(), + dockerDiscoverer: dockerdiscovery.New(), crypto: crypto.NewService(), privateKeyPath: appconf.AgentPrivateKeyPath(), }, nil @@ -77,6 +80,7 @@ func NewWithDependencies( netcollector networkmetrics.Collector, storagecollector storagemetrics.Collector, pgbouncercollector pgbouncermetrics.Collector, + dockerDiscoverer dockerdiscovery.Discoverer, crypto crypto.Service, privateKeyPath string, ) *metricspusher { @@ -88,6 +92,7 @@ func NewWithDependencies( netcollector: netcollector, storagecollector: storagecollector, pgbouncercollector: pgbouncercollector, + dockerDiscoverer: dockerDiscoverer, crypto: crypto, privateKeyPath: privateKeyPath, } @@ -158,17 +163,46 @@ func (mp *metricspusher) Push(cred credential.Credential) error { }) } - dbMetrics, err := mp.metricscollector.Collect(cred) + // Collect from control-plane credential (primary PG) + hasPrimaryCred := cred.Host != "" || cred.Port != 0 + if hasPrimaryCred { + dbMetrics, err := mp.metricscollector.Collect(cred) + if err != nil { + log.Warnf("primary database metrics collection failed: %v", err) + dbMetrics = domainmetrics.PostgreSQLDatabaseMetrics{Up: false} + } else { + dbMetrics.Up = true + } + metricSets = append(metricSets, domainmetrics.MetricSet{ + Type: domainmetrics.MetricTypePostgreSQLDatabase, + Metrics: dbMetrics, + }) + + pgbouncerMetrics, err := mp.pgbouncercollector.Collect(cred) + if err != nil { + pgbouncerMetrics = domainmetrics.PgBouncerMetrics{Up: false} + } else { + pgbouncerMetrics.Up = true + } + metricSets = append(metricSets, domainmetrics.MetricSet{ + Type: domainmetrics.MetricTypePgBouncer, + Metrics: pgbouncerMetrics, + }) + } + + // Discover Docker containers and collect PG metrics from each + dockerDBs, err := mp.dockerDiscoverer.DiscoverDatabases(ctx) if err != nil { - log.Warnf("database metrics collection failed: %v", err) - dbMetrics = domainmetrics.PostgreSQLDatabaseMetrics{Up: false} - } else { - dbMetrics.Up = true + log.Warnf("docker database discovery failed: %v", err) + } + for _, d := range dockerDBs { + switch d.Type { + case dockerdiscovery.DatabaseTypePostgreSQL: + mp.collectDockerPGMetrics(ctx, d, &metricSets) + default: + // MySQL and MongoDB collectors not yet implemented + } } - metricSets = append(metricSets, domainmetrics.MetricSet{ - Type: domainmetrics.MetricTypePostgreSQLDatabase, - Metrics: dbMetrics, - }) storageMetrics, err := mp.storagecollector.Collect(ctx) if err != nil { @@ -188,24 +222,6 @@ func (mp *metricspusher) Push(cred credential.Credential) error { } } - // PgBouncer stats — try-connect approach: silently skip when not running. - // The collector returns an error if PgBouncer is unreachable; we mark Up: false - // and still include the metric set so the server can track the pooler state. - pgbouncerMetrics, err := mp.pgbouncercollector.Collect(cred) - if err != nil { - pgbouncerMetrics = domainmetrics.PgBouncerMetrics{Up: false} - } else { - pgbouncerMetrics.Up = true - } - metricSets = append(metricSets, domainmetrics.MetricSet{ - Type: domainmetrics.MetricTypePgBouncer, - Metrics: pgbouncerMetrics, - }) - - // If only the postgresql.database metric set exists (with up=false) and - // all other collectors failed, we still push so the server knows the agent - // is alive and PostgreSQL status is reported. - hostname, _ := os.Hostname() payload := domainmetrics.MetricPayload{ @@ -220,3 +236,35 @@ func (mp *metricspusher) Push(cred credential.Credential) error { return mp.apiserver.PushMetrics(ctx, payload) } + +func (mp *metricspusher) collectDockerPGMetrics(ctx context.Context, d dockerdiscovery.DiscoveredDatabase, metricSets *[]domainmetrics.MetricSet) { + dockerCred := credential.Credential{ + Host: d.Host, + Port: int(d.Port), + Username: d.Username, + Database: d.Database, + Dialect: "postgresql", + } + if d.Password != "" { + dockerCred.Password = &d.Password + } + + dbMetrics, err := mp.metricscollector.Collect(dockerCred) + if err != nil { + log.Warnf("docker PG metrics collection failed for %s: %v", d.ContainerName, err) + dbMetrics = domainmetrics.PostgreSQLDatabaseMetrics{Up: false} + } else { + dbMetrics.Up = true + } + *metricSets = append(*metricSets, domainmetrics.MetricSet{ + Type: domainmetrics.MetricTypePostgreSQLDatabase, + Attributes: map[string]any{ + "container_id": d.ContainerID[:12], + "container_name": d.ContainerName, + "database_name": d.Database, + "port": d.Port, + "source": "docker", + }, + Metrics: dbMetrics, + }) +} diff --git a/app/services/metrics/metrics_test.go b/app/services/metrics/metrics_test.go index b30ab45..349a8bf 100644 --- a/app/services/metrics/metrics_test.go +++ b/app/services/metrics/metrics_test.go @@ -9,6 +9,7 @@ import ( "hostlink/domain/credential" domainmetrics "hostlink/domain/metrics" + "hostlink/internal/dockerdiscovery" "hostlink/internal/storagemetrics" "github.com/stretchr/testify/assert" @@ -189,6 +190,15 @@ func (m *MockPgBouncerCollector) Collect(cred credential.Credential) (domainmetr return args.Get(0).(domainmetrics.PgBouncerMetrics), args.Error(1) } +type MockDockerDiscoverer struct { + mock.Mock +} + +func (m *MockDockerDiscoverer) DiscoverDatabases(ctx context.Context) ([]dockerdiscovery.DiscoveredDatabase, error) { + args := m.Called(ctx) + return args.Get(0).([]dockerdiscovery.DiscoveredDatabase), args.Error(1) +} + // Test helpers type testMocks struct { apiserver *MockAPIServer @@ -198,6 +208,7 @@ type testMocks struct { netcollector *MockNetCollector storagecollector *MockStorageCollector pgbouncercollector *MockPgBouncerCollector + dockerDiscoverer *MockDockerDiscoverer crypto *MockCrypto } @@ -210,6 +221,7 @@ func setupTestMetricsPusher() (*metricspusher, *testMocks) { netcollector: new(MockNetCollector), storagecollector: new(MockStorageCollector), pgbouncercollector: new(MockPgBouncerCollector), + dockerDiscoverer: new(MockDockerDiscoverer), crypto: new(MockCrypto), } @@ -221,10 +233,15 @@ func setupTestMetricsPusher() (*metricspusher, *testMocks) { mocks.netcollector, mocks.storagecollector, mocks.pgbouncercollector, + mocks.dockerDiscoverer, mocks.crypto, "/test/key/path", ) + // Default: no Docker containers discovered + mocks.dockerDiscoverer.On("DiscoverDatabases", mock.Anything). + Return([]dockerdiscovery.DiscoveredDatabase{}, nil) + return mp, mocks } @@ -739,7 +756,7 @@ func TestPush_Success_ValidatesPayloadSchema(t *testing.T) { func TestPush_ContextPropagation(t *testing.T) { mp, mocks := setupTestMetricsPusher() - testCred := credential.Credential{DataDirectory: "/data"} + testCred := credential.Credential{Host: "localhost", Port: 5432, DataDirectory: "/data"} mocks.agentstate.On("GetAgentID").Return("agent-123") setupSysCollectorMocks(mocks.syscollector) @@ -911,7 +928,7 @@ func TestPush_DatabaseDown_SendsUpFalseWithZeroMetrics(t *testing.T) { // Verifies storage metrics are included in the payload func TestPush_IncludesStorageMetrics(t *testing.T) { mp, mocks := setupTestMetricsPusher() - testCred := credential.Credential{DataDirectory: "/data"} + testCred := credential.Credential{Host: "localhost", Port: 5432, DataDirectory: "/data"} mocks.agentstate.On("GetAgentID").Return("agent-123") setupSysCollectorMocks(mocks.syscollector) @@ -940,7 +957,7 @@ func TestPush_IncludesStorageMetrics(t *testing.T) { // Verifies when storage collection fails, other metrics still pushed func TestPush_StorageMetricsFailure_StillPushesOtherMetrics(t *testing.T) { mp, mocks := setupTestMetricsPusher() - testCred := credential.Credential{DataDirectory: "/data"} + testCred := credential.Credential{Host: "localhost", Port: 5432, DataDirectory: "/data"} mocks.agentstate.On("GetAgentID").Return("agent-123") setupSysCollectorMocks(mocks.syscollector) @@ -977,7 +994,7 @@ func TestPush_StorageMetricsFailure_StillPushesOtherMetrics(t *testing.T) { // Verifies each mount becomes a separate MetricSet func TestPush_StorageMetricsMultipleMounts(t *testing.T) { mp, mocks := setupTestMetricsPusher() - testCred := credential.Credential{DataDirectory: "/data"} + testCred := credential.Credential{Host: "localhost", Port: 5432, DataDirectory: "/data"} mocks.agentstate.On("GetAgentID").Return("agent-123") setupSysCollectorMocks(mocks.syscollector) @@ -1014,7 +1031,7 @@ func TestPush_StorageMetricsMultipleMounts(t *testing.T) { // Verifies attributes are set correctly func TestPush_StorageMetricsWithAttributes(t *testing.T) { mp, mocks := setupTestMetricsPusher() - testCred := credential.Credential{DataDirectory: "/data"} + testCred := credential.Credential{Host: "localhost", Port: 5432, DataDirectory: "/data"} mocks.agentstate.On("GetAgentID").Return("agent-123") setupSysCollectorMocks(mocks.syscollector) diff --git a/go.mod b/go.mod index 40f74c8..7ba2cfd 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v28.3.3+incompatible // indirect + github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -50,7 +50,6 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 394dfa1..7f2fc82 100644 --- a/go.sum +++ b/go.sum @@ -27,8 +27,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= -github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -62,8 +62,6 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -89,8 +87,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -178,8 +174,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -200,32 +194,17 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -234,22 +213,13 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= diff --git a/internal/dockerdiscovery/discoverer.go b/internal/dockerdiscovery/discoverer.go new file mode 100644 index 0000000..289a3da --- /dev/null +++ b/internal/dockerdiscovery/discoverer.go @@ -0,0 +1,276 @@ +package dockerdiscovery + +import ( + "context" + "fmt" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" +) + +type dockerClient interface { + ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) + ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) +} + +type DatabaseType string + +const ( + DatabaseTypePostgreSQL DatabaseType = "postgresql" + DatabaseTypeMySQL DatabaseType = "mysql" + DatabaseTypeMongoDB DatabaseType = "mongodb" +) + +type DiscoveredDatabase struct { + Type DatabaseType + ContainerID string + ContainerName string + Host string + Port uint16 + Database string + Username string + Password string +} + +type Discoverer interface { + DiscoverDatabases(ctx context.Context) ([]DiscoveredDatabase, error) +} + +type imageRule struct { + dbType DatabaseType + images []string + excludeNames []string +} + +var discoveryRules = []imageRule{ + { + dbType: DatabaseTypePostgreSQL, + images: []string{"postgres", "supabase/postgres"}, + excludeNames: []string{"coolify-db"}, + }, + { + dbType: DatabaseTypeMySQL, + images: []string{"mysql", "mariadb"}, + excludeNames: nil, + }, + { + dbType: DatabaseTypeMongoDB, + images: []string{"mongo"}, + excludeNames: nil, + }, +} + +type envExtractor func(envs []string) (username, password, database string) + +func extractPostgresEnv(envs []string) (string, string, string) { + username := "postgres" + database := "postgres" + password := "" + for _, env := range envs { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "POSTGRES_USER": + username = parts[1] + case "POSTGRES_DB": + database = parts[1] + case "POSTGRES_PASSWORD": + password = parts[1] + } + } + return username, password, database +} + +func extractMySQLEnv(envs []string) (string, string, string) { + username := "root" + database := "" + password := "" + for _, env := range envs { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "MYSQL_USER": + username = parts[1] + case "MYSQL_ROOT_PASSWORD": + if password == "" { + password = parts[1] + } + case "MYSQL_PASSWORD": + password = parts[1] + case "MYSQL_DATABASE": + database = parts[1] + } + } + return username, password, database +} + +func extractMongoEnv(envs []string) (string, string, string) { + username := "" + database := "" + password := "" + for _, env := range envs { + parts := strings.SplitN(env, "=", 2) + if len(parts) != 2 { + continue + } + switch parts[0] { + case "MONGO_INITDB_ROOT_USERNAME": + username = parts[1] + case "MONGO_INITDB_ROOT_PASSWORD": + password = parts[1] + case "MONGO_INITDB_DATABASE": + database = parts[1] + } + } + return username, password, database +} + +var envExtractors = map[DatabaseType]envExtractor{ + DatabaseTypePostgreSQL: extractPostgresEnv, + DatabaseTypeMySQL: extractMySQLEnv, + DatabaseTypeMongoDB: extractMongoEnv, +} + +var defaultDBName = map[DatabaseType]string{ + DatabaseTypePostgreSQL: "postgres", + DatabaseTypeMySQL: "", + DatabaseTypeMongoDB: "", +} + +var defaultPorts = map[DatabaseType]uint16{ + DatabaseTypePostgreSQL: 5432, + DatabaseTypeMySQL: 3306, + DatabaseTypeMongoDB: 27017, +} + +type dockerDiscoverer struct { + client dockerClient +} + +var _ Discoverer = (*dockerDiscoverer)(nil) + +func New() Discoverer { + cli, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return &noopDiscoverer{} + } + return &dockerDiscoverer{client: cli} +} + +func NewWithClient(cli dockerClient) Discoverer { + return &dockerDiscoverer{client: cli} +} + +func (d *dockerDiscoverer) DiscoverDatabases(ctx context.Context) ([]DiscoveredDatabase, error) { + var result []DiscoveredDatabase + for _, rule := range discoveryRules { + dbs, err := d.discoverByRule(ctx, rule) + if err != nil { + continue + } + result = append(result, dbs...) + } + return result, nil +} + +func (d *dockerDiscoverer) discoverByRule(ctx context.Context, rule imageRule) ([]DiscoveredDatabase, error) { + seen := map[string]bool{} + var result []DiscoveredDatabase + for _, image := range rule.images { + containers, err := d.client.ContainerList(ctx, container.ListOptions{ + Filters: filters.NewArgs(filters.Arg("ancestor", image)), + }) + if err != nil { + continue + } + for _, c := range containers { + if seen[c.ID] || isExcluded(c, rule.excludeNames) { + continue + } + seen[c.ID] = true + db, err := inspectContainer(ctx, d.client, c, rule.dbType) + if err != nil { + continue + } + result = append(result, db) + } + } + return result, nil +} + +func isExcluded(c container.Summary, excludes []string) bool { + if len(excludes) == 0 { + return false + } + for _, n := range c.Names { + name := strings.TrimPrefix(n, "/") + for _, ex := range excludes { + if name == ex || strings.HasPrefix(name, ex+"-") { + return true + } + } + } + return false +} + +func inspectContainer(ctx context.Context, cli dockerClient, c container.Summary, dbType DatabaseType) (DiscoveredDatabase, error) { + info, err := cli.ContainerInspect(ctx, c.ID) + if err != nil { + return DiscoveredDatabase{}, fmt.Errorf("inspect %s: %w", c.ID[:12], err) + } + + containerName := strings.TrimPrefix(c.Names[0], "/") + + port := defaultPorts[dbType] + if info.NetworkSettings != nil { + for containerPort, bindings := range info.NetworkSettings.Ports { + if strings.HasPrefix(string(containerPort), fmt.Sprintf("%d/", port)) && len(bindings) > 0 { + if p := bindings[0].HostPort; p != "" { + var parsed uint16 + if _, err := fmt.Sscanf(p, "%d", &parsed); err == nil && parsed > 0 { + port = parsed + break + } + } + } + } + } + + username, password, database := "", "", "" + if info.Config != nil { + extractor := envExtractors[dbType] + if extractor != nil { + username, password, database = extractor(info.Config.Env) + } + } + + if database == "" { + database = defaultDBName[dbType] + } + + return DiscoveredDatabase{ + Type: dbType, + ContainerID: c.ID, + ContainerName: containerName, + Host: "localhost", + Port: port, + Database: database, + Username: username, + Password: password, + }, nil +} + +type noopDiscoverer struct{} + +func (n *noopDiscoverer) DiscoverDatabases(_ context.Context) ([]DiscoveredDatabase, error) { + return nil, nil +} diff --git a/internal/dockerdiscovery/discoverer_test.go b/internal/dockerdiscovery/discoverer_test.go new file mode 100644 index 0000000..eb252b3 --- /dev/null +++ b/internal/dockerdiscovery/discoverer_test.go @@ -0,0 +1,347 @@ +package dockerdiscovery + +import ( + "context" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockDockerClient struct { + mock.Mock +} + +func (m *mockDockerClient) ContainerList(ctx context.Context, opts container.ListOptions) ([]container.Summary, error) { + args := m.Called(ctx, opts) + return args.Get(0).([]container.Summary), args.Error(1) +} + +func (m *mockDockerClient) ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error) { + args := m.Called(ctx, containerID) + return args.Get(0).(container.InspectResponse), args.Error(1) +} + +func (m *mockDockerClient) Close() error { + args := m.Called() + return args.Error(0) +} + +func (m *mockDockerClient) NegotiateAPIVersion(ctx context.Context) { + m.Called(ctx) +} + +func matchAncestor(image string) any { + return mock.MatchedBy(func(opts container.ListOptions) bool { + return opts.Filters.Match("ancestor", image) + }) +} + +func setupEmptyAllImages(cli *mockDockerClient) { + allImages := []string{"postgres", "supabase/postgres", "mysql", "mariadb", "mongo"} + for _, img := range allImages { + cli.On("ContainerList", mock.Anything, matchAncestor(img)).Return([]container.Summary{}, nil).Once() + } +} + +func TestDiscoverDatabases_NoContainers(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + setupEmptyAllImages(cli) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Empty(t, databases) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_PostgresContainer(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + containerID := "abc123def456" + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{ + {ID: containerID, Names: []string{"/my-postgres"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{}, nil).Once() + + cli.On("ContainerInspect", mock.Anything, containerID).Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("5432/tcp"): []nat.PortBinding{{HostPort: "15432"}}, + }, + }, + }, + Config: &container.Config{ + Env: []string{ + "POSTGRES_USER=customuser", + "POSTGRES_DB=customdb", + "POSTGRES_PASSWORD=secret123", + }, + }, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 1) + assert.Equal(t, DatabaseTypePostgreSQL, databases[0].Type) + assert.Equal(t, containerID, databases[0].ContainerID) + assert.Equal(t, "my-postgres", databases[0].ContainerName) + assert.Equal(t, "localhost", databases[0].Host) + assert.Equal(t, uint16(15432), databases[0].Port) + assert.Equal(t, "customdb", databases[0].Database) + assert.Equal(t, "customuser", databases[0].Username) + assert.Equal(t, "secret123", databases[0].Password) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_SupabasePostgresContainer(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + containerID := "sup123" + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{ + {ID: containerID, Names: []string{"/supabase-db"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{}, nil).Once() + + cli.On("ContainerInspect", mock.Anything, containerID).Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("5432/tcp"): []nat.PortBinding{{HostPort: "5432"}}, + }, + }, + }, + Config: &container.Config{ + Env: []string{ + "POSTGRES_USER=supabase_user", + "POSTGRES_DB=supabase_db", + "POSTGRES_PASSWORD=supabase_pass", + }, + }, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 1) + assert.Equal(t, DatabaseTypePostgreSQL, databases[0].Type) + assert.Equal(t, "supabase-db", databases[0].ContainerName) + assert.Equal(t, "supabase_user", databases[0].Username) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_MySQLContainer(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + containerID := "mysql111" + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{ + {ID: containerID, Names: []string{"/my-mysql"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{}, nil).Once() + + cli.On("ContainerInspect", mock.Anything, containerID).Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("3306/tcp"): []nat.PortBinding{{HostPort: "3307"}}, + }, + }, + }, + Config: &container.Config{ + Env: []string{ + "MYSQL_ROOT_PASSWORD=rootpass", + "MYSQL_DATABASE=mydb", + }, + }, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 1) + assert.Equal(t, DatabaseTypeMySQL, databases[0].Type) + assert.Equal(t, "my-mysql", databases[0].ContainerName) + assert.Equal(t, "root", databases[0].Username) + assert.Equal(t, "rootpass", databases[0].Password) + assert.Equal(t, uint16(3307), databases[0].Port) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_MongoDBContainer(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + containerID := "mongo222" + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{ + {ID: containerID, Names: []string{"/my-mongo"}}, + }, nil).Once() + + cli.On("ContainerInspect", mock.Anything, containerID).Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("27017/tcp"): []nat.PortBinding{{HostPort: "27018"}}, + }, + }, + }, + Config: &container.Config{ + Env: []string{ + "MONGO_INITDB_ROOT_USERNAME=admin", + "MONGO_INITDB_ROOT_PASSWORD=mongopass", + }, + }, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 1) + assert.Equal(t, DatabaseTypeMongoDB, databases[0].Type) + assert.Equal(t, "my-mongo", databases[0].ContainerName) + assert.Equal(t, "admin", databases[0].Username) + assert.Equal(t, "mongopass", databases[0].Password) + assert.Equal(t, uint16(27018), databases[0].Port) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_MultipleTypes(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{ + {ID: "pg1", Names: []string{"/pg-one"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{ + {ID: "mysql1", Names: []string{"/mysql-one"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{}, nil).Once() + + cli.On("ContainerInspect", mock.Anything, "pg1").Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("5432/tcp"): []nat.PortBinding{{HostPort: "5432"}}, + }, + }, + }, + Config: &container.Config{Env: []string{"POSTGRES_PASSWORD=pgpass"}}, + }, nil) + cli.On("ContainerInspect", mock.Anything, "mysql1").Return(container.InspectResponse{ + NetworkSettings: &container.NetworkSettings{ + NetworkSettingsBase: container.NetworkSettingsBase{ + Ports: nat.PortMap{ + nat.Port("3306/tcp"): []nat.PortBinding{{HostPort: "3306"}}, + }, + }, + }, + Config: &container.Config{Env: []string{"MYSQL_ROOT_PASSWORD=mysqlpass"}}, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 2) + types := map[DatabaseType]bool{} + for _, db := range databases { + types[db.Type] = true + } + assert.True(t, types[DatabaseTypePostgreSQL]) + assert.True(t, types[DatabaseTypeMySQL]) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_FiltersCoolifyDB(t *testing.T) { + cli := new(mockDockerClient) + d := NewWithClient(cli) + + cli.On("ContainerList", mock.Anything, matchAncestor("postgres")).Return([]container.Summary{ + {ID: "111", Names: []string{"/coolify-db"}}, + {ID: "222", Names: []string{"/my-app-db"}}, + }, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("supabase/postgres")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mysql")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mariadb")).Return([]container.Summary{}, nil).Once() + cli.On("ContainerList", mock.Anything, matchAncestor("mongo")).Return([]container.Summary{}, nil).Once() + + cli.On("ContainerInspect", mock.Anything, "222").Return(container.InspectResponse{ + Config: &container.Config{Env: []string{"POSTGRES_PASSWORD=pass"}}, + }, nil) + + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Len(t, databases, 1) + assert.Equal(t, "my-app-db", databases[0].ContainerName) + cli.AssertExpectations(t) +} + +func TestDiscoverDatabases_DockerUnavailable(t *testing.T) { + d := New() + databases, err := d.DiscoverDatabases(context.Background()) + + assert.NoError(t, err) + assert.Empty(t, databases) +} + +func TestEnvExtractors(t *testing.T) { + t.Run("postgres defaults", func(t *testing.T) { + u, p, d := extractPostgresEnv([]string{"SOME_VAR=val"}) + assert.Equal(t, "postgres", u) + assert.Equal(t, "", p) + assert.Equal(t, "postgres", d) + }) + + t.Run("postgres custom", func(t *testing.T) { + u, p, d := extractPostgresEnv([]string{ + "POSTGRES_USER=appuser", + "POSTGRES_PASSWORD=apppass", + "POSTGRES_DB=appdb", + }) + assert.Equal(t, "appuser", u) + assert.Equal(t, "apppass", p) + assert.Equal(t, "appdb", d) + }) + + t.Run("mysql root password", func(t *testing.T) { + u, p, d := extractMySQLEnv([]string{ + "MYSQL_ROOT_PASSWORD=rootpass", + "MYSQL_DATABASE=mydb", + }) + assert.Equal(t, "root", u) + assert.Equal(t, "rootpass", p) + assert.Equal(t, "mydb", d) + }) + + t.Run("mongo", func(t *testing.T) { + u, p, d := extractMongoEnv([]string{ + "MONGO_INITDB_ROOT_USERNAME=admin", + "MONGO_INITDB_ROOT_PASSWORD=mongopass", + }) + assert.Equal(t, "admin", u) + assert.Equal(t, "mongopass", p) + assert.Equal(t, "", d) + }) +}