mirror of
https://github.com/TronoSfera/backupy-agent.git
synced 2026-05-18 18:13:30 +03:00
Source ports from the TronoSfera/backupy-cloud monorepo:
- apps/agent/ — Go agent (WSS client, persistent queue, Docker
discovery, 5 DB drivers: PG/MySQL/Mongo/Redis/SQLite,
pre/post hooks, Prometheus metrics)
- apps/backupy-decrypt/ — standalone CLI for client-side decryption
- packages/proto/ — protobuf wire format (generated .pb.go committed
so the repo builds without protoc)
- docs/ — agent spec + wire-protocol contract
Apache-2.0 license. Image published to ghcr.io/tronosfera/backupy-agent
on every v* tag via .github/workflows/release.yml (multi-arch amd64+arm64).
232 lines
6.8 KiB
Go
232 lines
6.8 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeDockerServer wires a single httptest.Server that pretends to be the
|
|
// Docker Engine HTTP API on the configured API-version prefix.
|
|
func fakeDockerServer(t *testing.T, list []map[string]any, inspect map[string]map[string]any) *httptest.Server {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
listPath := "/" + dockerAPIVersion + "/containers/json"
|
|
mux.HandleFunc(listPath, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(list)
|
|
})
|
|
mux.HandleFunc("/"+dockerAPIVersion+"/containers/", func(w http.ResponseWriter, r *http.Request) {
|
|
// expect path: /vX/containers/{id}/json
|
|
trim := strings.TrimPrefix(r.URL.Path, "/"+dockerAPIVersion+"/containers/")
|
|
id := strings.TrimSuffix(trim, "/json")
|
|
if id == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
body, ok := inspect[id]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
})
|
|
return httptest.NewServer(mux)
|
|
}
|
|
|
|
func TestScan_DetectsPostgres(t *testing.T) {
|
|
srv := fakeDockerServer(t,
|
|
[]map[string]any{{
|
|
"Id": "abc",
|
|
"Names": []string{"/db"},
|
|
"Image": "postgres:16",
|
|
"State": "running",
|
|
}},
|
|
map[string]map[string]any{
|
|
"abc": {
|
|
"Id": "abc",
|
|
"Name": "/db",
|
|
"State": map[string]any{"Running": true},
|
|
"Config": map[string]any{
|
|
"Image": "postgres:16",
|
|
"Env": []string{"POSTGRES_USER=app", "POSTGRES_PASSWORD=hunter2", "POSTGRES_DB=app", "PATH=/usr/bin"},
|
|
},
|
|
"NetworkSettings": map[string]any{
|
|
"Networks": map[string]any{"bridge": map[string]any{}},
|
|
"Ports": map[string]any{
|
|
"5432/tcp": []map[string]any{{"HostIp": "0.0.0.0", "HostPort": "5432"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
defer srv.Close()
|
|
|
|
s := newDockerScanner(srv.URL, nil)
|
|
got, err := s.Scan(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, "postgresql", got[0].DetectedDBType)
|
|
require.Equal(t, "db", got[0].Name)
|
|
require.Contains(t, got[0].EnvHints, "POSTGRES_PASSWORD")
|
|
require.NotContains(t, got[0].EnvHints, "PATH", "PATH should be filtered out")
|
|
// Critically: values must not leak — we replace with "set".
|
|
for _, v := range got[0].EnvHints {
|
|
require.Equal(t, "set", v, "env values must never appear in EnvHints")
|
|
}
|
|
require.Len(t, got[0].Ports, 1)
|
|
require.Equal(t, uint32(5432), got[0].Ports[0].ContainerPort)
|
|
require.Equal(t, uint32(5432), got[0].Ports[0].HostPort)
|
|
require.Equal(t, "tcp", got[0].Ports[0].Protocol)
|
|
}
|
|
|
|
func TestScan_DetectsAllSupportedTypes(t *testing.T) {
|
|
cases := []struct {
|
|
image string
|
|
dbType string
|
|
}{
|
|
{"postgres:16", "postgresql"},
|
|
{"postgres", "postgresql"},
|
|
{"timescale/timescaledb:latest-pg16", "postgresql"},
|
|
{"mysql:8.0", "mysql"},
|
|
{"percona:8", "mysql"},
|
|
{"mariadb:11", "mariadb"},
|
|
{"mongo:7", "mongodb"},
|
|
{"redis:7-alpine", "redis"},
|
|
}
|
|
list := make([]map[string]any, 0, len(cases))
|
|
inspect := make(map[string]map[string]any, len(cases))
|
|
for i, c := range cases {
|
|
id := "c" + string(rune('a'+i))
|
|
list = append(list, map[string]any{
|
|
"Id": id, "Names": []string{"/" + id}, "Image": c.image, "State": "running",
|
|
})
|
|
inspect[id] = map[string]any{
|
|
"Id": id,
|
|
"Name": "/" + id,
|
|
"State": map[string]any{"Running": true},
|
|
"Config": map[string]any{
|
|
"Image": c.image,
|
|
"Env": []string{},
|
|
},
|
|
}
|
|
}
|
|
srv := fakeDockerServer(t, list, inspect)
|
|
defer srv.Close()
|
|
|
|
s := newDockerScanner(srv.URL, nil)
|
|
got, err := s.Scan(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, got, len(cases))
|
|
|
|
gotByImage := make(map[string]string)
|
|
for _, c := range got {
|
|
gotByImage[c.Image] = c.DetectedDBType
|
|
}
|
|
for _, c := range cases {
|
|
require.Equal(t, c.dbType, gotByImage[c.image], "image %q", c.image)
|
|
}
|
|
}
|
|
|
|
func TestScan_SkipsNonDBImages(t *testing.T) {
|
|
srv := fakeDockerServer(t,
|
|
[]map[string]any{
|
|
{"Id": "x", "Names": []string{"/web"}, "Image": "nginx:1.27", "State": "running"},
|
|
{"Id": "y", "Names": []string{"/app"}, "Image": "myorg/api:1.2", "State": "running"},
|
|
// mysqld-exporter must NOT be classified as mysql.
|
|
{"Id": "z", "Names": []string{"/exp"}, "Image": "prom/mysqld-exporter", "State": "running"},
|
|
},
|
|
map[string]map[string]any{},
|
|
)
|
|
defer srv.Close()
|
|
|
|
s := newDockerScanner(srv.URL, nil)
|
|
got, err := s.Scan(context.Background())
|
|
require.NoError(t, err)
|
|
require.Empty(t, got)
|
|
}
|
|
|
|
func TestScan_SkipsStoppedContainers(t *testing.T) {
|
|
srv := fakeDockerServer(t,
|
|
[]map[string]any{{"Id": "abc", "Names": []string{"/db"}, "Image": "postgres:16", "State": "exited"}},
|
|
map[string]map[string]any{
|
|
"abc": {
|
|
"Id": "abc",
|
|
"Name": "/db",
|
|
"State": map[string]any{"Running": false},
|
|
"Config": map[string]any{"Image": "postgres:16", "Env": []string{}},
|
|
},
|
|
},
|
|
)
|
|
defer srv.Close()
|
|
|
|
s := newDockerScanner(srv.URL, nil)
|
|
got, err := s.Scan(context.Background())
|
|
require.NoError(t, err)
|
|
require.Empty(t, got)
|
|
}
|
|
|
|
func TestScan_ContainerWithNoPublishedPorts(t *testing.T) {
|
|
srv := fakeDockerServer(t,
|
|
[]map[string]any{{"Id": "abc", "Names": []string{"/db"}, "Image": "postgres:16", "State": "running"}},
|
|
map[string]map[string]any{
|
|
"abc": {
|
|
"Id": "abc",
|
|
"Name": "/db",
|
|
"State": map[string]any{"Running": true},
|
|
"Config": map[string]any{
|
|
"Image": "postgres:16",
|
|
"Env": []string{"POSTGRES_USER=app"},
|
|
},
|
|
"NetworkSettings": map[string]any{
|
|
"Ports": map[string]any{
|
|
// EXPOSE 5432 with no host binding.
|
|
"5432/tcp": nil,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
defer srv.Close()
|
|
|
|
s := newDockerScanner(srv.URL, nil)
|
|
got, err := s.Scan(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1)
|
|
require.Len(t, got[0].Ports, 1)
|
|
require.Equal(t, uint32(5432), got[0].Ports[0].ContainerPort)
|
|
require.Equal(t, uint32(0), got[0].Ports[0].HostPort, "exposed-but-not-published HostPort must be 0")
|
|
}
|
|
|
|
func TestNormaliseImage(t *testing.T) {
|
|
cases := map[string]string{
|
|
"postgres:16": "postgres:16",
|
|
"ghcr.io/example/postgres:16": "example/postgres:16",
|
|
"localhost:5000/mariadb:latest": "mariadb:latest",
|
|
"postgres@sha256:deadbeef": "postgres",
|
|
"BITNAMI/Postgresql:14": "bitnami/postgresql:14",
|
|
}
|
|
for in, want := range cases {
|
|
require.Equal(t, want, normaliseImage(in), "input %q", in)
|
|
}
|
|
}
|
|
|
|
func TestBuildReport_NoValueLeakage(t *testing.T) {
|
|
report := BuildReport([]DiscoveredContainer{{
|
|
ContainerID: "abc",
|
|
Name: "db",
|
|
Image: "postgres:16",
|
|
DetectedDBType: "postgresql",
|
|
EnvHints: map[string]string{"POSTGRES_USER": "set", "POSTGRES_PASSWORD": "set"},
|
|
}})
|
|
require.Len(t, report.Containers, 1)
|
|
for _, v := range report.Containers[0].EnvHints {
|
|
require.Equal(t, "set", v)
|
|
}
|
|
}
|