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) + }) +}