Logging: - structured slog with LOG_FORMAT=json|text and a stable key vocabulary - periodic stats record with queue depths, db pool, goroutines, memory, dns/probe - no per-certificate logging (hot path is counters only) UI (Bucketeer branding, DESIGN.md): - light+dark themes (OS default, persisted header toggle, no first-paint flash) - official mascot: lossless WebP light/dark logos + favicon, embedded and served with cache headers (1.2MB source PNGs -> ~27/22KB webp) - Assets/Contents views, pagination (offset) and native virtual scrolling (content-visibility) so large result sets do not blow up the DOM - search with debounce/abort + URL state, exposure chips, sort, summary counts Readability + indexing: - internal/index listers for S3-compatible, GCS, Azure, Firebase, OpenSearch (metadata only: keys/sizes/dates; never object contents) - asset_entries table + store write/search; readable resources are indexed and searchable in the UI - deep, unauthenticated, read-only DB probes (PROBE_DEEP): Redis PING, PostgreSQL StartupMessage, MongoDB OP_MSG listDatabases; OpenSearch/Neptune HTTP reads - Azure account-level listing fixed: 403 is reported reachable, not auth_required, since anonymous access is per-container DNS: - curated reliable resolvers by default; DNS_SERVERS=fresh adds the compiled-in Fresh-Resolvers snapshot with a bounded startup sample (warming all 6.7k at once broke the container resolver) - random healthy selection + failover + per-server cooldown Other: prober Close fixes a shutdown hang; config/README updated.
115 lines
4.1 KiB
Go
115 lines
4.1 KiB
Go
package index
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"tea.karlcom.de/karl/bucketeer/internal/model"
|
|
)
|
|
|
|
func newTestIndexer(handler http.Handler) (*Indexer, string) {
|
|
srv := httptest.NewServer(handler)
|
|
x := New(50, 2*time.Second)
|
|
x.scheme = "http"
|
|
x.gcsBase = srv.URL
|
|
return x, strings.TrimPrefix(srv.URL, "http://")
|
|
}
|
|
|
|
func TestListS3(t *testing.T) {
|
|
body := `<?xml version="1.0"?><ListBucketResult>
|
|
<IsTruncated>false</IsTruncated>
|
|
<CommonPrefixes><Prefix>photos/</Prefix></CommonPrefixes>
|
|
<Contents><Key>photos/a.jpg</Key><LastModified>2024-01-02T03:04:05.000Z</LastModified><ETag>"abc"</ETag><Size>1234</Size></Contents>
|
|
<Contents><Key>readme.txt</Key><Size>12</Size></Contents>
|
|
</ListBucketResult>`
|
|
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Query().Get("list-type") != "2" {
|
|
t.Errorf("expected list-type=2, got %q", r.URL.RawQuery)
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 3 {
|
|
t.Fatalf("got %d entries: %+v", len(entries), entries)
|
|
}
|
|
if entries[0].Kind != "prefix" || entries[0].Name != "photos/" {
|
|
t.Fatalf("prefix entry wrong: %+v", entries[0])
|
|
}
|
|
if entries[1].Name != "photos/a.jpg" || entries[1].Size != 1234 || entries[1].ETag != "abc" {
|
|
t.Fatalf("object entry wrong: %+v", entries[1])
|
|
}
|
|
if entries[1].Updated.IsZero() {
|
|
t.Fatal("expected updated timestamp parsed")
|
|
}
|
|
}
|
|
|
|
func TestListGCS(t *testing.T) {
|
|
body := `{"items":[{"name":"a/b.txt","size":"42","updated":"2024-05-06T07:08:09Z","contentType":"text/plain","etag":"e1"}],"prefixes":["a/"]}`
|
|
x, _ := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasPrefix(r.URL.Path, "/storage/v1/b/") {
|
|
t.Errorf("unexpected path %q", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
entries, err := x.List(context.Background(), model.Candidate{Resource: "mybucket", Provider: model.ProviderGCP, Service: model.ServiceObjectStorage})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 || entries[1].Name != "a/b.txt" || entries[1].Size != 42 || entries[1].ContentType != "text/plain" {
|
|
t.Fatalf("unexpected entries: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestListFirebase(t *testing.T) {
|
|
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"users":true,"orders":true}`))
|
|
}))
|
|
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderGCP, Service: model.ServiceFirebaseDB})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 || entries[0].Kind != "key" {
|
|
t.Fatalf("unexpected entries: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestListOpenSearch(t *testing.T) {
|
|
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`[{"index":"logs-2024","docs.count":"100","store.size":"1.2mb"}]`))
|
|
}))
|
|
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceOpenSearch})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Name != "logs-2024" || entries[0].Size != 100 {
|
|
t.Fatalf("unexpected entries: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestListBounded(t *testing.T) {
|
|
var keys strings.Builder
|
|
for i := 0; i < 100; i++ {
|
|
keys.WriteString("<Contents><Key>k")
|
|
keys.WriteString(string(rune('a' + i%26)))
|
|
keys.WriteString("</Key><Size>1</Size></Contents>")
|
|
}
|
|
body := `<ListBucketResult><IsTruncated>false</IsTruncated>` + keys.String() + `</ListBucketResult>`
|
|
x, host := newTestIndexer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
|
|
x.maxObjects = 10
|
|
entries, err := x.List(context.Background(), model.Candidate{Hostname: host, Provider: model.ProviderAWS, Service: model.ServiceS3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) > 10 {
|
|
t.Fatalf("got %d entries, want <= 10", len(entries))
|
|
}
|
|
}
|