diff --git a/cmd/synthetic-monitoring-agent/main.go b/cmd/synthetic-monitoring-agent/main.go index 72cb96d88..a1907743c 100644 --- a/cmd/synthetic-monitoring-agent/main.go +++ b/cmd/synthetic-monitoring-agent/main.go @@ -314,7 +314,7 @@ func run(args []string, stdout io.Writer) error { publisher := publisherFactory(ctx, tm, zl.With().Str("subsystem", "publisher").Str("version", config.SelectedPublisher).Logger(), promRegisterer) limits := limits.NewTenantLimits(tm) - secrets := secrets.NewTenantSecrets(tm, zl.With().Str("subsystem", "secretstore").Logger()) + secretProvider := secrets.NewSecretProvider(tm, 60*time.Second, zl.With().Str("subsystem", "secretstore").Logger()) telemetry := telemetry.NewTelemeter( ctx, uuid.New().String(), time.Duration(config.TelemetryTimeSpan)*time.Minute, @@ -335,7 +335,7 @@ func run(args []string, stdout io.Writer) error { K6Runner: k6Runner, ScraperFactory: scraper.New, TenantLimits: limits, - TenantSecrets: secrets, + SecretProvider: secretProvider, Telemeter: telemetry, UsageReporter: usageReporter, }) @@ -356,7 +356,7 @@ func run(args []string, stdout io.Writer) error { PromRegisterer: promRegisterer, Features: features, K6Runner: k6Runner, - TenantSecrets: secrets, + SecretProvider: secretProvider, }) if err != nil { return fmt.Errorf("cannot create ad-hoc checks handler: %w", err) diff --git a/go.mod b/go.mod index 8210abca8..f0a6fdf36 100644 --- a/go.mod +++ b/go.mod @@ -34,9 +34,11 @@ require ( github.com/felixge/httpsnoop v1.0.4 github.com/go-kit/log v0.2.1 github.com/gogo/status v1.1.1 + github.com/grafana/gsm-api-go-client v0.2.0 github.com/grafana/loki/pkg/push v0.0.0-20250903135404-0b2d0b070e96 github.com/jpillora/backoff v1.0.0 github.com/mccutchen/go-httpbin/v2 v2.18.3 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/prometheus-community/pro-bing v0.7.0 github.com/quasilyte/go-ruleguard/dsl v0.3.23 github.com/spf13/afero v1.15.0 @@ -49,6 +51,7 @@ require ( cel.dev/expr v0.24.0 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -61,17 +64,20 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.16.1 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect + go.k6.io/k6 v1.1.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.29.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect diff --git a/go.sum b/go.sum index cb87dfcdf..7a3f74705 100644 --- a/go.sum +++ b/go.sum @@ -16,12 +16,15 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= @@ -52,6 +55,7 @@ github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/buger/goterm v0.0.0-20181115115552-c206103e1f37/go.mod h1:u9UyCz2eTrSGy6fbupqJ54eY5c4IC8gREQ1053dK12U= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= @@ -104,6 +108,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/grafana/gsm-api-go-client v0.2.0 h1:FcnZIIFHNqMxIzzvX9zsoFBmv6uys1b9e3snBCsuaPw= +github.com/grafana/gsm-api-go-client v0.2.0/go.mod h1:IvCUDWc0SYtCdjJnw+HgtRCzEkjVIIGH/kU7b50s5xw= github.com/grafana/loki/pkg/push v0.0.0-20250903135404-0b2d0b070e96 h1:wdu17bfMs5kQZHWzXG6abUHBZNLhIHE6K+LN8PvRIHk= github.com/grafana/loki/pkg/push v0.0.0-20250903135404-0b2d0b070e96/go.mod h1:QClrwZWT4u0N/O0Z0zKnRn36D51XTJMMmy7ICgaECd0= github.com/grafana/mtr v0.1.1-0.20221107202107-a9806fdda166 h1:COSDtVDArtLKK9p+mkUlPXCfWFslQFVVuQos39vxQrU= @@ -114,6 +120,7 @@ github.com/hokaccha/go-prettyjson v0.0.0-20180920040306-f579f869bbfe/go.mod h1:p github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= 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= @@ -143,9 +150,13 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -178,17 +189,22 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -199,6 +215,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.k6.io/k6 v1.1.0 h1:kKAJTSmaEaaTGbw9rB2K49So0ul0kf05loZGXsI4Dxo= +go.k6.io/k6 v1.1.0/go.mod h1:C68dyEQUUZ1MSCPpFdQcjdtydAgNTOASNKhkDF7fiHg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -248,6 +266,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/adhoc/adhoc.go b/internal/adhoc/adhoc.go index 2195834bc..b390e1c81 100644 --- a/internal/adhoc/adhoc.go +++ b/internal/adhoc/adhoc.go @@ -8,10 +8,8 @@ import ( "io" "time" - "github.com/grafana/synthetic-monitoring-agent/internal/secrets" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/prometheus/prompb" + prompb "github.com/prometheus/prometheus/prompb" "github.com/rs/zerolog" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -24,6 +22,7 @@ import ( "github.com/grafana/synthetic-monitoring-agent/internal/model" "github.com/grafana/synthetic-monitoring-agent/internal/prober" "github.com/grafana/synthetic-monitoring-agent/internal/pusher" + "github.com/grafana/synthetic-monitoring-agent/internal/secrets" "github.com/grafana/synthetic-monitoring-agent/internal/version" sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" ) @@ -112,7 +111,7 @@ type HandlerOpts struct { PromRegisterer prometheus.Registerer Features feature.Collection K6Runner k6runner.Runner - TenantSecrets *secrets.TenantSecrets + SecretProvider secrets.SecretProvider // these two fields exists so that tests can pass alternate // implementations, they are unexported so that clients of this @@ -145,7 +144,7 @@ func NewHandler(opts HandlerOpts) (*Handler, error) { tenantCh: opts.TenantCh, runnerFactory: opts.runnerFactory, grpcAdhocChecksClientFactory: opts.grpcAdhocChecksClientFactory, - proberFactory: prober.NewProberFactory(opts.K6Runner, 0, opts.Features, opts.TenantSecrets), + proberFactory: prober.NewProberFactory(opts.K6Runner, 0, opts.Features, opts.SecretProvider), api: apiInfo{ conn: opts.Conn, }, diff --git a/internal/checks/checks.go b/internal/checks/checks.go index 7c83ec69c..ffdc8f1e8 100644 --- a/internal/checks/checks.go +++ b/internal/checks/checks.go @@ -81,7 +81,7 @@ type Updater struct { k6Runner k6runner.Runner scraperFactory scraper.Factory tenantLimits *limits.TenantLimits - tenantSecrets *secrets.TenantSecrets + tenantSecrets secrets.SecretProvider telemeter *telemetry.Telemeter usageReporter usage.Reporter } @@ -117,8 +117,8 @@ type UpdaterOptions struct { K6Runner k6runner.Runner ScraperFactory scraper.Factory TenantLimits *limits.TenantLimits + SecretProvider secrets.SecretProvider Telemeter *telemetry.Telemeter - TenantSecrets *secrets.TenantSecrets UsageReporter usage.Reporter } @@ -241,7 +241,7 @@ func NewUpdater(opts UpdaterOptions) (*Updater, error) { k6Runner: opts.K6Runner, scraperFactory: scraperFactory, tenantLimits: opts.TenantLimits, - tenantSecrets: opts.TenantSecrets, + tenantSecrets: opts.SecretProvider, telemeter: opts.Telemeter, metrics: metrics{ changeErrorsCounter: changeErrorsCounter, diff --git a/internal/checks/checks_test.go b/internal/checks/checks_test.go index 0aca7e8f2..abcb0abb8 100644 --- a/internal/checks/checks_test.go +++ b/internal/checks/checks_test.go @@ -473,7 +473,7 @@ func testScraperFactory(ctx context.Context, check model.Check, publisher pusher k6Runner k6runner.Runner, labelsLimiter scraper.LabelsLimiter, telemeter *telemetry.Telemeter, - secretStore *secrets.TenantSecrets, + secretStore secrets.SecretProvider, ) (*scraper.Scraper, error) { return scraper.NewWithOpts( ctx, diff --git a/internal/prober/http/http.go b/internal/prober/http/http.go index 293f5cad8..3f752d200 100644 --- a/internal/prober/http/http.go +++ b/internal/prober/http/http.go @@ -12,7 +12,9 @@ import ( "time" "github.com/grafana/synthetic-monitoring-agent/internal/model" + "github.com/grafana/synthetic-monitoring-agent/internal/prober/interpolation" "github.com/grafana/synthetic-monitoring-agent/internal/prober/logger" + "github.com/grafana/synthetic-monitoring-agent/internal/secrets" "github.com/grafana/synthetic-monitoring-agent/internal/tls" "github.com/grafana/synthetic-monitoring-agent/internal/version" sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" @@ -26,11 +28,18 @@ import ( var errUnsupportedCheck = errors.New("unsupported check") type Prober struct { - config config.Module + // Raw settings and dependencies for runtime secret resolution + settings *sm.HttpSettings + timeout time.Duration + secretStore secrets.SecretProvider + tenantID model.GlobalID + logger zerolog.Logger cacheBustingQueryParamName string + // Static config that doesn't need secret resolution + staticConfig config.Module } -func NewProber(ctx context.Context, check model.Check, logger zerolog.Logger, reservedHeaders http.Header) (Prober, error) { +func NewProber(ctx context.Context, check model.Check, logger zerolog.Logger, reservedHeaders http.Header, secretStore secrets.SecretProvider) (Prober, error) { if check.Settings.Http == nil { return Prober{}, errUnsupportedCheck } @@ -39,16 +48,22 @@ func NewProber(ctx context.Context, check model.Check, logger zerolog.Logger, re augmentHttpHeaders(&check.Check, reservedHeaders) } - cfg, err := settingsToModule(ctx, check.Settings.Http, logger) + // Build static configuration (everything except authentication secrets) + staticCfg, err := buildStaticConfig(check.Settings.Http) if err != nil { return Prober{}, err } - cfg.Timeout = time.Duration(check.Timeout) * time.Millisecond + staticCfg.Timeout = time.Duration(check.Timeout) * time.Millisecond return Prober{ - config: cfg, + settings: check.Settings.Http, + timeout: time.Duration(check.Timeout) * time.Millisecond, + secretStore: secretStore, + tenantID: check.GlobalTenantID(), + logger: logger.With().Str("prober", "http").Logger(), cacheBustingQueryParamName: check.Settings.Http.CacheBustingQueryParamName, + staticConfig: staticCfg, }, nil } @@ -63,10 +78,54 @@ func (p Prober) Probe(ctx context.Context, target string, registry *prometheus.R target = addCacheBustParam(target, p.cacheBustingQueryParamName, target) } - return bbeprober.ProbeHTTP(ctx, target, p.config, registry, slogger), 0 + // Resolve secrets and build complete config at probe time + probeConfig, err := p.buildProbeConfig(ctx) + if err != nil { + p.logger.Error().Err(err).Msg("failed to resolve secrets for HTTP probe") + return false, 0 + } + + return bbeprober.ProbeHTTP(ctx, target, probeConfig, registry, slogger), 0 +} + +// buildProbeConfig creates the complete configuration with resolved secrets +func (p Prober) buildProbeConfig(ctx context.Context) (config.Module, error) { + // Start with static config + cfg := p.staticConfig + + // Resolve authentication secrets at probe time + httpClientConfig, err := buildPrometheusHTTPClientConfig( + ctx, + p.settings, + p.logger, + p.secretStore, + p.tenantID, + ) + if err != nil { + return cfg, fmt.Errorf("failed to build HTTP client config: %w", err) + } + + cfg.HTTP.HTTPClientConfig = httpClientConfig + + // Set BBE's SkipResolvePhaseWithProxy when a proxy is configured + if cfg.HTTP.HTTPClientConfig.ProxyURL.URL != nil { + cfg.HTTP.SkipResolvePhaseWithProxy = true + } + + // Handle OAuth2 config if present + if p.settings.Oauth2Config != nil && p.settings.Oauth2Config.ClientId != "" { + oauth2Config, err := convertOAuth2Config(ctx, p.settings.Oauth2Config, p.logger) + if err != nil { + return cfg, fmt.Errorf("parsing OAuth2 settings: %w", err) + } + cfg.HTTP.HTTPClientConfig.OAuth2 = oauth2Config + } + + return cfg, nil } -func settingsToModule(ctx context.Context, settings *sm.HttpSettings, logger zerolog.Logger) (config.Module, error) { +// buildStaticConfig creates the parts of the config that don't require secret resolution +func buildStaticConfig(settings *sm.HttpSettings) (config.Module, error) { var m config.Module m.Prober = sm.CheckTypeHttp.String() @@ -143,34 +202,27 @@ func settingsToModule(ctx context.Context, settings *sm.HttpSettings, logger zer }) } - var err error - m.HTTP.HTTPClientConfig, err = buildPrometheusHTTPClientConfig( - ctx, - settings, - logger.With().Str("prober", m.Prober).Logger(), - ) - if err != nil { - return m, err - } + return m, nil +} - // Set BBE's SkipResolvePhaseWithProxy when a proxy is configured to avoid resolving the target. - // DNS should be done at the proxy server only. - if m.HTTP.HTTPClientConfig.ProxyURL.URL != nil { - m.HTTP.SkipResolvePhaseWithProxy = true +// resolveSecretValue resolves a secret value using string interpolation with ${secrets.secret_name} syntax. +// If secretManagerEnabled is false, the value is returned as-is without any interpolation. +func resolveSecretValue(ctx context.Context, value string, secretStore secrets.SecretProvider, tenantID model.GlobalID, logger zerolog.Logger, secretManagerEnabled bool) (string, error) { + if value == "" { + return "", nil } - if settings.Oauth2Config != nil && settings.Oauth2Config.ClientId != "" { - var err error - m.HTTP.HTTPClientConfig.OAuth2, err = convertOAuth2Config(ctx, settings.Oauth2Config, logger.With().Str("prober", m.Prober).Logger()) - if err != nil { - return m, fmt.Errorf("parsing OAuth2 settings: %w", err) - } + // If secret manager is not enabled, return the value as-is + if !secretManagerEnabled { + return value, nil } - return m, nil + // Create a resolver that only handles secrets (no variables) + resolver := interpolation.NewResolver(nil, secretStore, tenantID, logger, secretManagerEnabled) + return resolver.Resolve(ctx, value) } -func buildPrometheusHTTPClientConfig(ctx context.Context, settings *sm.HttpSettings, logger zerolog.Logger) (promconfig.HTTPClientConfig, error) { +func buildPrometheusHTTPClientConfig(ctx context.Context, settings *sm.HttpSettings, logger zerolog.Logger, secretStore secrets.SecretProvider, tenantID model.GlobalID) (promconfig.HTTPClientConfig, error) { var cfg promconfig.HTTPClientConfig // Enable HTTP2 for all checks. @@ -197,18 +249,29 @@ func buildPrometheusHTTPClientConfig(ctx context.Context, settings *sm.HttpSetti if settings.TlsConfig != nil { var err error - cfg.TLSConfig, err = tls.SMtoProm(ctx, logger, settings.TlsConfig) + cfg.TLSConfig, err = buildTLSConfig(ctx, settings.TlsConfig, secretStore, tenantID, logger, settings.SecretManagerEnabled) if err != nil { return cfg, err } } - cfg.BearerToken = promconfig.Secret(settings.BearerToken) + // Resolve bearer token (may be a secret) + bearerToken, err := resolveSecretValue(ctx, settings.BearerToken, secretStore, tenantID, logger, settings.SecretManagerEnabled) + if err != nil { + return cfg, fmt.Errorf("failed to resolve bearer token: %w", err) + } + cfg.BearerToken = promconfig.Secret(bearerToken) if settings.BasicAuth != nil { + // Resolve password (may be a secret) + password, err := resolveSecretValue(ctx, settings.BasicAuth.Password, secretStore, tenantID, logger, settings.SecretManagerEnabled) + if err != nil { + return cfg, fmt.Errorf("failed to resolve basic auth password: %w", err) + } + cfg.BasicAuth = &promconfig.BasicAuth{ Username: settings.BasicAuth.Username, - Password: promconfig.Secret(settings.BasicAuth.Password), + Password: promconfig.Secret(password), } } @@ -232,6 +295,60 @@ func buildPrometheusHTTPClientConfig(ctx context.Context, settings *sm.HttpSetti return cfg, nil } +// buildTLSConfig builds a Prometheus TLS config from SM TLS config with secret resolution support +func buildTLSConfig(ctx context.Context, tlsConfig *sm.TLSConfig, secretStore secrets.SecretProvider, tenantID model.GlobalID, logger zerolog.Logger, secretManagerEnabled bool) (promconfig.TLSConfig, error) { + // Create a copy of the TLS config with resolved secrets + resolvedTLSConfig := &sm.TLSConfig{ + InsecureSkipVerify: tlsConfig.InsecureSkipVerify, + ServerName: tlsConfig.ServerName, + } + + // Resolve CA cert if present + if len(tlsConfig.CACert) > 0 { + if secretManagerEnabled { + // Resolve CA cert from secret if secret manager is enabled + caCertStr, err := resolveSecretValue(ctx, string(tlsConfig.CACert), secretStore, tenantID, logger, secretManagerEnabled) + if err != nil { + return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve CA cert: %w", err) + } + resolvedTLSConfig.CACert = []byte(caCertStr) + } else { + resolvedTLSConfig.CACert = tlsConfig.CACert + } + } + + // Resolve client cert if present + if len(tlsConfig.ClientCert) > 0 { + if secretManagerEnabled { + // Resolve client cert from secret if secret manager is enabled + clientCertStr, err := resolveSecretValue(ctx, string(tlsConfig.ClientCert), secretStore, tenantID, logger, secretManagerEnabled) + if err != nil { + return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve client cert: %w", err) + } + resolvedTLSConfig.ClientCert = []byte(clientCertStr) + } else { + resolvedTLSConfig.ClientCert = tlsConfig.ClientCert + } + } + + // Resolve client key if present + if len(tlsConfig.ClientKey) > 0 { + if secretManagerEnabled { + // Resolve client key from secret if secret manager is enabled + clientKeyStr, err := resolveSecretValue(ctx, string(tlsConfig.ClientKey), secretStore, tenantID, logger, secretManagerEnabled) + if err != nil { + return promconfig.TLSConfig{}, fmt.Errorf("failed to resolve client key: %w", err) + } + resolvedTLSConfig.ClientKey = []byte(clientKeyStr) + } else { + resolvedTLSConfig.ClientKey = tlsConfig.ClientKey + } + } + + // Use the existing TLS conversion function with resolved config + return tls.SMtoProm(ctx, logger, resolvedTLSConfig) +} + func convertOAuth2Config(ctx context.Context, cfg *sm.OAuth2Config, logger zerolog.Logger) (*promconfig.OAuth2, error) { r := &promconfig.OAuth2{} r.ClientID = cfg.ClientId diff --git a/internal/prober/http/http_test.go b/internal/prober/http/http_test.go index 10e774e84..89172696b 100644 --- a/internal/prober/http/http_test.go +++ b/internal/prober/http/http_test.go @@ -6,12 +6,12 @@ import ( "io" "net/http" "net/url" - "strings" "testing" "github.com/go-kit/log" "github.com/grafana/synthetic-monitoring-agent/internal/model" "github.com/grafana/synthetic-monitoring-agent/internal/prober/http/testserver" + "github.com/grafana/synthetic-monitoring-agent/internal/testhelper" "github.com/grafana/synthetic-monitoring-agent/internal/version" sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" "github.com/prometheus/blackbox_exporter/config" @@ -22,6 +22,17 @@ import ( "github.com/stretchr/testify/require" ) +// expectedHeaders creates expected headers map with user-agent included +func expectedHeaders(extra map[string]string) map[string]string { + headers := map[string]string{ + "user-agent": version.UserAgent(), + } + for k, v := range extra { + headers[k] = v + } + return headers +} + func TestName(t *testing.T) { name := Prober.Name(Prober{}) require.Equal(t, name, "http") @@ -30,8 +41,7 @@ func TestName(t *testing.T) { func TestNewProber(t *testing.T) { testcases := map[string]struct { input model.Check - expected Prober - ExpectError bool + expectError bool }{ "default": { input: model.Check{Check: sm.Check{ @@ -45,12 +55,7 @@ func TestNewProber(t *testing.T) { }, }, }}, - expected: Prober{ - config: getDefaultModule(). - addHttpHeader("X-Sm-Id", "3-3"). // is checkId twice since probeId is unavailable here - getConfigModule(), - }, - ExpectError: false, + expectError: false, }, "no-settings": { input: model.Check{Check: sm.Check{ @@ -60,8 +65,7 @@ func TestNewProber(t *testing.T) { Http: nil, }, }}, - expected: Prober{}, - ExpectError: true, + expectError: true, }, "headers": { input: model.Check{ @@ -79,32 +83,29 @@ func TestNewProber(t *testing.T) { }, }, }, - expected: Prober{ - config: getDefaultModule(). - addHttpHeader("uSeR-aGeNt", "test-user-agent"). - addHttpHeader("some-header", "some-value"). - addHttpHeader("X-Sm-Id", "5-5"). - getConfigModule(), - }, - ExpectError: false, + expectError: false, }, } for name, testcase := range testcases { ctx := context.Background() - logger := zerolog.New(io.Discard) + logger := testhelper.NewTestLogger() t.Run(name, func(t *testing.T) { // origin identifier for http requests is checkId-probeId; testing with checkId twice in the absence of probeId checkId := testcase.input.Id reservedHeaders := http.Header{} reservedHeaders.Add("x-sm-id", fmt.Sprintf("%d-%d", checkId, checkId)) - actual, err := NewProber(ctx, testcase.input, logger, reservedHeaders) - require.Equal(t, &testcase.expected, &actual) - if testcase.ExpectError { + actual, err := NewProber(ctx, testcase.input, logger, reservedHeaders, nil) + + if testcase.expectError { require.Error(t, err, "unsupported check") } else { require.NoError(t, err) + // Verify that the prober was created with the expected settings + require.NotNil(t, actual.settings) + require.Equal(t, testcase.input.Settings.Http, actual.settings) + require.Equal(t, testcase.input.GlobalTenantID(), actual.tenantID) } }) } @@ -234,7 +235,7 @@ func TestProbe(t *testing.T) { zl := zerolog.Logger{} kl := log.NewLogfmtLogger(io.Discard) - prober, err := NewProber(ctx, check, zl, http.Header{}) + prober, err := NewProber(ctx, check, zl, http.Header{}, nil) require.NoError(t, err) success, duration := prober.Probe(ctx, check.Target, registry, kl) @@ -280,27 +281,20 @@ func TestBuildHeaders(t *testing.T) { expected map[string]string }{ "nil": { - input: nil, - expected: map[string]string{ - "user-agent": version.UserAgent(), - }, + input: nil, + expected: expectedHeaders(nil), }, "empty": { - input: []string{}, - expected: map[string]string{ - "user-agent": version.UserAgent(), - }, + input: []string{}, + expected: expectedHeaders(nil), }, "trivial": { input: []string{ "foo: bar", }, - expected: map[string]string{ - "foo": "bar", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"foo": "bar"}), }, "multiple headers": { @@ -308,11 +302,7 @@ func TestBuildHeaders(t *testing.T) { "h1: v1", "h2: v2", }, - expected: map[string]string{ - "h1": "v1", - "h2": "v2", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), }, "compact": { @@ -320,11 +310,7 @@ func TestBuildHeaders(t *testing.T) { "h1:v1", "h2:v2", }, - expected: map[string]string{ - "h1": "v1", - "h2": "v2", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), }, "trim leading whitespace": { @@ -332,11 +318,7 @@ func TestBuildHeaders(t *testing.T) { "h1: v1", "h2: v2", }, - expected: map[string]string{ - "h1": "v1", - "h2": "v2", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), }, "keep trailing whitespace": { @@ -344,11 +326,7 @@ func TestBuildHeaders(t *testing.T) { "h1: v1 ", "h2: v2 ", }, - expected: map[string]string{ - "h1": "v1 ", - "h2": "v2 ", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"h1": "v1 ", "h2": "v2 "}), }, "empty values": { @@ -356,11 +334,7 @@ func TestBuildHeaders(t *testing.T) { "h1: ", "h2:", }, - expected: map[string]string{ - "h1": "", - "h2": "", - "user-agent": version.UserAgent(), - }, + expected: expectedHeaders(map[string]string{"h1": "", "h2": ""}), }, "custom user agent": { @@ -412,26 +386,19 @@ func TestSettingsToModule(t *testing.T) { setHttpBody("This is a body"). getConfigModule(), }, - "proxy-settings": { - input: sm.HttpSettings{ - ProxyURL: "http://example.org/", - ProxyConnectHeaders: []string{"h1: v1", "h2:v2"}, - }, - expected: getDefaultModule(). - setProxyUrl("http://example.org/"). - setProxyConnectHeaders(map[string]string{"h1": "v1", "h2": "v2"}). - setSkipResolvePhaseWithProxy(true). - getConfigModule(), - }, } for name, testcase := range testcases { - ctx := context.Background() - logger := zerolog.New(io.Discard) t.Run(name, func(t *testing.T) { - actual, err := settingsToModule(ctx, &testcase.input, logger) + actual, err := buildStaticConfig(&testcase.input) require.NoError(t, err) - require.Equal(t, &testcase.expected, &actual) + + // Note: buildStaticConfig doesn't include HTTP client config + // so we need to remove that from expected results for this test + expected := testcase.expected + expected.HTTP.HTTPClientConfig = httpConfig.HTTPClientConfig{} + + require.Equal(t, &expected, &actual) }) } } @@ -490,22 +457,6 @@ func (m *testModule) getConfigModule() config.Module { return config.Module(*m) } -func (m *testModule) addHttpHeader(key, value string) *testModule { - if m.HTTP.Headers == nil { - m.HTTP.Headers = make(map[string]string) - } - - for k := range m.HTTP.Headers { - if strings.EqualFold(k, key) { - delete(m.HTTP.Headers, k) - } - } - - m.HTTP.Headers[key] = value - - return m -} - func (m *testModule) addHttpValidStatusCodes(code int) *testModule { m.HTTP.ValidStatusCodes = append(m.HTTP.ValidStatusCodes, code) return m @@ -521,24 +472,541 @@ func (m *testModule) setHttpBody(body string) *testModule { return m } -func (m *testModule) setProxyUrl(u string) *testModule { - var err error - m.HTTP.HTTPClientConfig.ProxyURL.URL, err = url.Parse(u) - if err != nil { - panic(err) +func TestNewProberWithSecretStore(t *testing.T) { + ctx := context.Background() + logger := testhelper.NewTestLogger() + + check := model.Check{Check: sm.Check{ + Id: 1, + Target: "www.grafana.com", + Settings: sm.CheckSettings{ + Http: &sm.HttpSettings{}, + }, + }} + + // Test with nil secret store (should work) + _, err := NewProber(ctx, check, logger, http.Header{}, nil) + require.NoError(t, err) + + // This test verifies that the secretStore parameter is properly + // accepted and passed through the call chain without causing errors +} + +func TestResolveSecretValue(t *testing.T) { + ctx := context.Background() + logger := testhelper.NewTestLogger() + tenantID := model.GlobalID(123) + + testcases := map[string]struct { + input string + mockSecretFunc func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) + expectedOutput string + expectError bool + }{ + "empty value": { + input: "", + expectedOutput: "", + expectError: false, + }, + "secret interpolation with valid secret": { + input: "${secrets.my-secret-key}", + mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + if secretKey == "my-secret-key" { + return "secret-value-from-gsm", nil + } + return "", fmt.Errorf("secret not found") + }, + expectedOutput: "secret-value-from-gsm", + expectError: false, + }, + "secret interpolation with secret lookup error": { + input: "${secrets.non-existent-key}", + mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + return "", fmt.Errorf("secret not found") + }, + expectedOutput: "", + expectError: true, + }, + "secret interpolation with empty secret name": { + input: "${secrets.}", + expectedOutput: "", + expectError: true, + }, + "plaintext value (no interpolation)": { + input: "my-plain-password", + expectedOutput: "my-plain-password", + expectError: false, + }, + "mixed interpolation and plaintext": { + input: "Bearer ${secrets.my-token}", + mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + if secretKey == "my-token" { + return "actual-token-value", nil + } + return "", fmt.Errorf("secret not found") + }, + expectedOutput: "Bearer actual-token-value", + expectError: false, + }, + "multiple secrets in one string": { + input: "${secrets.username}:${secrets.password}", + mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + switch secretKey { + case "username": + return "admin", nil + case "password": + return "secret123", nil + default: + return "", fmt.Errorf("secret not found") + } + }, + expectedOutput: "admin:secret123", + expectError: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + // Create mock secret store + var mockSecretStore *testhelper.MockSecretProvider + if tc.mockSecretFunc != nil { + mockSecretStore = testhelper.NewMockSecretProviderWithFunc(tc.mockSecretFunc) + } + + // Use mock secret store directly + secretStore := mockSecretStore + + actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, true) + + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, actual) + } + }) } - return m } -func (m *testModule) setProxyConnectHeaders(headers map[string]string) *testModule { - m.HTTP.HTTPClientConfig.ProxyConnectHeader = make(httpConfig.ProxyHeader) - for k, v := range headers { - m.HTTP.HTTPClientConfig.ProxyConnectHeader[k] = []httpConfig.Secret{httpConfig.Secret(v)} +func TestBuildPrometheusHTTPClientConfig_WithSecrets(t *testing.T) { + ctx, logger, tenantID := testhelper.CommonTestSetup() + + // Mock secret store that returns known values + mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ + "bearer-token-key": "bearer-secret-value", + "password-key": "password-secret-value", + }) + + testcases := map[string]struct { + settings sm.HttpSettings + expectedBearer string + expectedPasswd string + }{ + "secret interpolation": { + settings: sm.HttpSettings{ + BearerToken: "${secrets.bearer-token-key}", + SecretManagerEnabled: true, + BasicAuth: &sm.BasicAuth{ + Username: "testuser", + Password: "${secrets.password-key}", + }, + }, + expectedBearer: "bearer-secret-value", + expectedPasswd: "password-secret-value", + }, + "plaintext values": { + settings: sm.HttpSettings{ + BearerToken: "plain-bearer-token", + SecretManagerEnabled: true, + BasicAuth: &sm.BasicAuth{ + Username: "testuser", + Password: "plain-password", + }, + }, + expectedBearer: "plain-bearer-token", + expectedPasswd: "plain-password", + }, + "mixed interpolation and plaintext": { + settings: sm.HttpSettings{ + BearerToken: "${secrets.bearer-token-key}", + SecretManagerEnabled: true, + BasicAuth: &sm.BasicAuth{ + Username: "testuser", + Password: "plain-password", + }, + }, + expectedBearer: "bearer-secret-value", + expectedPasswd: "plain-password", + }, + "complex interpolation": { + settings: sm.HttpSettings{ + BearerToken: "Bearer ${secrets.bearer-token-key}", + SecretManagerEnabled: true, + BasicAuth: &sm.BasicAuth{ + Username: "testuser", + Password: "${secrets.password-key}", + }, + }, + expectedBearer: "Bearer bearer-secret-value", + expectedPasswd: "password-secret-value", + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + // Use mock secret store directly + secretStore := mockSecretStore + + cfg, err := buildPrometheusHTTPClientConfig(ctx, &tc.settings, logger, secretStore, tenantID) + require.NoError(t, err) + + require.Equal(t, tc.expectedBearer, string(cfg.BearerToken)) + if tc.settings.BasicAuth != nil { + require.NotNil(t, cfg.BasicAuth) + require.Equal(t, tc.expectedPasswd, string(cfg.BasicAuth.Password)) + require.Equal(t, tc.settings.BasicAuth.Username, cfg.BasicAuth.Username) + } + }) } - return m } -func (m *testModule) setSkipResolvePhaseWithProxy(value bool) *testModule { - m.HTTP.SkipResolvePhaseWithProxy = value - return m +func TestResolveSecretValueWithCapabilityFromSecretStore(t *testing.T) { + ctx, logger, tenantID := testhelper.CommonTestSetup() + + // Mock secret store that should never be called when capability is disabled + mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ + "my-bearer-token": "resolved-bearer-token", + }) + + t.Run("with EnableProtocolSecrets=true", func(t *testing.T) { + // Create secret store with capability enabled + secretStore := mockSecretStore + + testcases := map[string]struct { + input string + expectedOutput string + expectError bool + }{ + "secret interpolation resolved when capability enabled": { + input: "${secrets.my-bearer-token}", + expectedOutput: "resolved-bearer-token", + expectError: false, + }, + "plaintext value unchanged when capability enabled": { + input: "my-plain-password", + expectedOutput: "my-plain-password", + expectError: false, + }, + "mixed interpolation and plaintext when capability enabled": { + input: "Bearer ${secrets.my-bearer-token}", + expectedOutput: "Bearer resolved-bearer-token", + expectError: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, true) + + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, actual) + } + }) + } + }) + + t.Run("with EnableProtocolSecrets=false", func(t *testing.T) { + // Mock that should never be called + failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + t.Fatal("GetSecretValue should not be called when EnableProtocolSecrets is false") + return "", nil + }) + + // Create secret store with capability disabled + secretStore := failingMockStore + + testcases := map[string]struct { + input string + expectedOutput string + }{ + "secret interpolation preserved when capability disabled": { + input: "${secrets.my-bearer-token}", + expectedOutput: "${secrets.my-bearer-token}", + }, + "plaintext value unchanged when capability disabled": { + input: "my-plain-password", + expectedOutput: "my-plain-password", + }, + "mixed interpolation preserved when capability disabled": { + input: "Bearer ${secrets.my-bearer-token}", + expectedOutput: "Bearer ${secrets.my-bearer-token}", + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, false) + + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, actual) + }) + } + }) + + t.Run("with nil capabilities (defaults to false)", func(t *testing.T) { + // Mock that should never be called + failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + t.Fatal("GetSecretValue should not be called when capabilities are nil") + return "", nil + }) + + // Create secret store with nil capabilities + secretStore := failingMockStore + + actual, err := resolveSecretValue(ctx, "gsm:my-bearer-token", secretStore, tenantID, logger, false) + require.NoError(t, err) + require.Equal(t, "gsm:my-bearer-token", actual) + }) + + t.Run("with regular SecretProvider (no capability awareness)", func(t *testing.T) { + // When using a regular SecretProvider, should default to false (no resolution) + failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + t.Fatal("GetSecretValue should not be called when no capability interface is implemented") + return "", nil + }) + + actual, err := resolveSecretValue(ctx, "gsm:my-bearer-token", failingMockStore, tenantID, logger, false) + require.NoError(t, err) + require.Equal(t, "gsm:my-bearer-token", actual) + }) +} + +func TestUpdatableSecretProvider(t *testing.T) { + ctx, logger, tenantID := testhelper.CommonTestSetup() + + // Mock secret store + mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ + "my-bearer-token": "resolved-bearer-token", + }) + + // Create updatable secret store + updatableStore := mockSecretStore + + t.Run("defaults to disabled", func(t *testing.T) { + require.False(t, updatableStore.IsProtocolSecretsEnabled()) + + // Should not resolve secrets when disabled + actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) + require.NoError(t, err) + require.Equal(t, "${secrets.my-bearer-token}", actual) + }) + + t.Run("can be updated to enabled", func(t *testing.T) { + // Update capabilities to enable protocol secrets + capabilities := &sm.Probe_Capabilities{ + EnableProtocolSecrets: true, + } + updatableStore.UpdateCapabilities(capabilities) + + require.True(t, updatableStore.IsProtocolSecretsEnabled()) + + // Should now resolve secrets + actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, true) + require.NoError(t, err) + require.Equal(t, "resolved-bearer-token", actual) + }) + + t.Run("can be updated to disabled", func(t *testing.T) { + // Update capabilities to disable protocol secrets + capabilities := &sm.Probe_Capabilities{ + EnableProtocolSecrets: false, + } + updatableStore.UpdateCapabilities(capabilities) + + require.False(t, updatableStore.IsProtocolSecretsEnabled()) + + // Should not resolve secrets when disabled + actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) + require.NoError(t, err) + require.Equal(t, "${secrets.my-bearer-token}", actual) + }) + + t.Run("handles nil capabilities", func(t *testing.T) { + // Update with nil capabilities (should default to disabled) + updatableStore.UpdateCapabilities(nil) + + require.False(t, updatableStore.IsProtocolSecretsEnabled()) + + // Should not resolve secrets when disabled + actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) + require.NoError(t, err) + require.Equal(t, "${secrets.my-bearer-token}", actual) + }) +} + +func TestResolveSecretValueWithSecretManagerEnabled(t *testing.T) { + ctx, logger, tenantID := testhelper.CommonTestSetup() + + // Mock secret store + mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ + "my-bearer-token": "resolved-bearer-token", + }) + + testcases := map[string]struct { + input string + secretManagerEnabled bool + expectedOutput string + expectError bool + }{ + "secret manager enabled with secret interpolation": { + input: "${secrets.my-bearer-token}", + secretManagerEnabled: true, + expectedOutput: "resolved-bearer-token", + expectError: false, + }, + "secret manager enabled with plaintext value": { + input: "my-plain-password", + secretManagerEnabled: true, + expectedOutput: "my-plain-password", + expectError: false, + }, + "secret manager enabled with mixed interpolation": { + input: "Bearer ${secrets.my-bearer-token}", + secretManagerEnabled: true, + expectedOutput: "Bearer resolved-bearer-token", + expectError: false, + }, + "secret manager disabled with secret interpolation": { + input: "${secrets.my-bearer-token}", + secretManagerEnabled: false, + expectedOutput: "${secrets.my-bearer-token}", + expectError: false, + }, + "secret manager disabled with plaintext value": { + input: "my-plain-password", + secretManagerEnabled: false, + expectedOutput: "my-plain-password", + expectError: false, + }, + "secret manager disabled with mixed interpolation": { + input: "Bearer ${secrets.my-bearer-token}", + secretManagerEnabled: false, + expectedOutput: "Bearer ${secrets.my-bearer-token}", + expectError: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + actual, err := resolveSecretValue(ctx, tc.input, mockSecretStore, tenantID, logger, tc.secretManagerEnabled) + + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedOutput, actual) + } + }) + } +} + +func TestBuildTLSConfig_WithSecrets(t *testing.T) { + ctx, logger, tenantID := testhelper.CommonTestSetup() + + // Mock secret store that returns known values + mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ + "ca-cert-key": "-----BEGIN CERTIFICATE-----\nCA_CERT_CONTENT\n-----END CERTIFICATE-----", + "client-cert-key": "-----BEGIN CERTIFICATE-----\nCLIENT_CERT_CONTENT\n-----END CERTIFICATE-----", + "client-key-key": "-----BEGIN PRIVATE KEY-----\nCLIENT_KEY_CONTENT\n-----END PRIVATE KEY-----", + }) + + testcases := map[string]struct { + tlsConfig *sm.TLSConfig + secretManagerEnabled bool + expectError bool + }{ + "TLS with secret interpolation": { + tlsConfig: &sm.TLSConfig{ + InsecureSkipVerify: false, + ServerName: "example.com", + CACert: []byte("${secrets.ca-cert-key}"), + ClientCert: []byte("${secrets.client-cert-key}"), + ClientKey: []byte("${secrets.client-key-key}"), + }, + secretManagerEnabled: true, + expectError: false, + }, + "TLS with plain values": { + tlsConfig: &sm.TLSConfig{ + InsecureSkipVerify: true, + ServerName: "test.com", + CACert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CA_CERT\n-----END CERTIFICATE-----"), + ClientCert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CLIENT_CERT\n-----END CERTIFICATE-----"), + ClientKey: []byte("-----BEGIN PRIVATE KEY-----\nPLAIN_CLIENT_KEY\n-----END PRIVATE KEY-----"), + }, + secretManagerEnabled: true, + expectError: false, + }, + "TLS with secret manager disabled": { + tlsConfig: &sm.TLSConfig{ + InsecureSkipVerify: false, + ServerName: "example.com", + CACert: []byte("${secrets.ca-cert-key}"), + ClientCert: []byte("${secrets.client-cert-key}"), + ClientKey: []byte("${secrets.client-key-key}"), + }, + secretManagerEnabled: false, + expectError: false, + }, + "TLS with mixed secret and plain values": { + tlsConfig: &sm.TLSConfig{ + InsecureSkipVerify: false, + ServerName: "mixed.com", + CACert: []byte("${secrets.ca-cert-key}"), + ClientCert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CLIENT_CERT\n-----END CERTIFICATE-----"), + ClientKey: []byte("${secrets.client-key-key}"), + }, + secretManagerEnabled: true, + expectError: false, + }, + "TLS with only some fields": { + tlsConfig: &sm.TLSConfig{ + InsecureSkipVerify: false, + ServerName: "partial.com", + CACert: []byte("${secrets.ca-cert-key}"), + // ClientCert and ClientKey are empty + }, + secretManagerEnabled: true, + expectError: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + cfg, err := buildTLSConfig(ctx, tc.tlsConfig, mockSecretStore, tenantID, logger, tc.secretManagerEnabled) + + if tc.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.tlsConfig.InsecureSkipVerify, cfg.InsecureSkipVerify) + require.Equal(t, tc.tlsConfig.ServerName, cfg.ServerName) + + // Verify that files were created for non-empty fields + if len(tc.tlsConfig.CACert) > 0 { + require.NotEmpty(t, cfg.CAFile) + } + if len(tc.tlsConfig.ClientCert) > 0 { + require.NotEmpty(t, cfg.CertFile) + } + if len(tc.tlsConfig.ClientKey) > 0 { + require.NotEmpty(t, cfg.KeyFile) + } + }) + } } diff --git a/internal/prober/interpolation/interpolation_test.go b/internal/prober/interpolation/interpolation_test.go index d42e436a3..302a41fe0 100644 --- a/internal/prober/interpolation/interpolation_test.go +++ b/internal/prober/interpolation/interpolation_test.go @@ -1,12 +1,10 @@ package interpolation import ( - "context" "fmt" "testing" - "github.com/grafana/synthetic-monitoring-agent/internal/model" - "github.com/rs/zerolog" + "github.com/grafana/synthetic-monitoring-agent/internal/testhelper" "github.com/stretchr/testify/require" ) @@ -22,22 +20,8 @@ func (m *mockVariableProvider) GetVariable(name string) (string, error) { return "", fmt.Errorf("variable '%s' not found", name) } -// mockSecretProvider is a mock implementation of SecretProvider for testing -type mockSecretProvider struct { - secrets map[string]string -} - -func (m *mockSecretProvider) GetSecretValue(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { - if value, exists := m.secrets[secretKey]; exists { - return value, nil - } - return "", fmt.Errorf("secret '%s' not found", secretKey) -} - func TestResolver_Resolve(t *testing.T) { - ctx := context.Background() - logger := zerolog.New(nil) - tenantID := model.GlobalID(123) + ctx, logger, tenantID := testhelper.CommonTestSetup() // Mock providers variableProvider := &mockVariableProvider{ @@ -49,15 +33,13 @@ func TestResolver_Resolve(t *testing.T) { }, } - secretProvider := &mockSecretProvider{ - secrets: map[string]string{ - "api-token": "secret-token-123", - "db-password": "secret-password", - "empty-secret": "", - "auth-config": "username=${username}&token=${api-token}", - "random-password": "my-password-${random}", - }, - } + secretProvider := testhelper.NewMockSecretProvider(map[string]string{ + "api-token": "secret-token-123", + "db-password": "secret-password", + "empty-secret": "", + "auth-config": "username=${username}&token=${api-token}", + "random-password": "my-password-${random}", + }) testcases := map[string]struct { input string diff --git a/internal/prober/prober.go b/internal/prober/prober.go index f404d0283..8c73ef994 100644 --- a/internal/prober/prober.go +++ b/internal/prober/prober.go @@ -71,7 +71,7 @@ func (f proberFactory) New(ctx context.Context, logger zerolog.Logger, check mod case sm.CheckTypeHttp: reservedHeaders := f.getReservedHeaders(&check) - p, err = httpProber.NewProber(ctx, check, logger, reservedHeaders) + p, err = httpProber.NewProber(ctx, check, logger, reservedHeaders, f.secretStore) target = check.Target case sm.CheckTypeDns: diff --git a/internal/scraper/scraper.go b/internal/scraper/scraper.go index 67e5ae615..8b931c013 100644 --- a/internal/scraper/scraper.go +++ b/internal/scraper/scraper.go @@ -87,7 +87,7 @@ type Factory func( k6runner k6runner.Runner, labelsLimiter LabelsLimiter, telemeter *telemetry.Telemeter, - secretStore *secrets.TenantSecrets, + secretStore secrets.SecretProvider, ) (*Scraper, error) type ( @@ -121,7 +121,7 @@ func New( k6runner k6runner.Runner, labelsLimiter LabelsLimiter, telemeter *telemetry.Telemeter, - secretStore *secrets.TenantSecrets, + secretStore secrets.SecretProvider, ) (*Scraper, error) { return NewWithOpts(ctx, check, ScraperOpts{ Probe: probe, @@ -275,7 +275,7 @@ func (h *scrapeHandler) scrape(ctx context.Context, t time.Time) { case errors.Is(err, errCheckFailed): h.scraper.metrics.AddCheckError() h.sm.fail(func() { - h.scraper.logger.Info().Msg("check entered FAIL state") + h.scraper.logger.Info().Err(err).Msg("check entered FAIL state") }) case err != nil: diff --git a/internal/scraper/scraper_test.go b/internal/scraper/scraper_test.go index 3b6701db5..156f2d97d 100644 --- a/internal/scraper/scraper_test.go +++ b/internal/scraper/scraper_test.go @@ -277,11 +277,13 @@ func setupHTTPProbe(ctx context.Context, t *testing.T) (prober.Prober, model.Che }, } + var store testhelper.NoopSecretStore prober, err := httpProber.NewProber( ctx, check, zerolog.New(io.Discard), http.Header{}, + store, ) if err != nil { t.Fatalf("cannot create HTTP prober: %s", err) @@ -311,11 +313,13 @@ func setupHTTPSSLProbe(ctx context.Context, t *testing.T) (prober.Prober, model. }, } + var store testhelper.NoopSecretStore prober, err := httpProber.NewProber( ctx, check, zerolog.New(io.Discard), http.Header{}, + store, ) if err != nil { t.Fatalf("cannot create HTTP prober: %s", err) diff --git a/internal/secrets/gsm_client.go b/internal/secrets/gsm_client.go new file mode 100644 index 000000000..7e27e6edd --- /dev/null +++ b/internal/secrets/gsm_client.go @@ -0,0 +1,44 @@ +package secrets + +import ( + "context" + "fmt" + "net/http" + + gsmClient "github.com/grafana/gsm-api-go-client" +) + +// GSMClientFactory creates GSM clients with proper configuration +type GSMClientFactory struct{} + +// NewGSMClientFactory creates a new GSM client factory +func NewGSMClientFactory() *GSMClientFactory { + return &GSMClientFactory{} +} + +// CreateClient creates a new GSM client with the provided URL and token +func (f *GSMClientFactory) CreateClient(url, token string) (gsmClient.ClientWithResponsesInterface, error) { + if url == "" { + return nil, fmt.Errorf("GSM URL cannot be empty") + } + if token == "" { + return nil, fmt.Errorf("GSM token cannot be empty") + } + return gsmClient.NewClientWithResponses(url, withAuth(token), withAcceptJSON()) +} + +// withAuth adds the Authorization header with Bearer token +func withAuth(token string) gsmClient.ClientOption { + return gsmClient.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Add("Authorization", "Bearer "+token) + return nil + }) +} + +// withAcceptJSON adds the Accept: application/json header +func withAcceptJSON() gsmClient.ClientOption { + return gsmClient.WithRequestEditorFn(func(_ context.Context, req *http.Request) error { + req.Header.Add("Accept", "application/json") + return nil + }) +} diff --git a/internal/secrets/tenant.go b/internal/secrets/tenant.go index 81e1b4045..5f6426101 100644 --- a/internal/secrets/tenant.go +++ b/internal/secrets/tenant.go @@ -2,7 +2,11 @@ package secrets import ( "context" + "fmt" + "net/http" + "time" + "github.com/patrickmn/go-cache" "github.com/rs/zerolog" "github.com/grafana/synthetic-monitoring-agent/internal/model" @@ -12,6 +16,7 @@ import ( type SecretProvider interface { GetSecretCredentials(ctx context.Context, tenantID model.GlobalID) (*sm.SecretStore, error) GetSecretValue(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) + IsProtocolSecretsEnabled() bool } type TenantProvider interface { @@ -20,15 +25,17 @@ type TenantProvider interface { // TenantSecrets provides backward compatibility with existing code type TenantSecrets struct { - tp TenantProvider - logger zerolog.Logger + tp TenantProvider + logger zerolog.Logger + enableProtocolSecrets bool } // NewTenantSecrets creates a new TenantSecrets instance for backward compatibility func NewTenantSecrets(tp TenantProvider, logger zerolog.Logger) *TenantSecrets { return &TenantSecrets{ - tp: tp, - logger: logger, + tp: tp, + logger: logger, + enableProtocolSecrets: false, // Default to false for backward compatibility } } @@ -72,3 +79,213 @@ func (ts *TenantSecrets) GetSecretValue(ctx context.Context, tenantID model.Glob // This will be replaced by the full implementation in PR 2 return "", nil } + +// IsProtocolSecretsEnabled returns whether protocol secrets are enabled for this probe +func (ts *TenantSecrets) IsProtocolSecretsEnabled() bool { + return ts.enableProtocolSecrets +} + +// UpdateCapabilities updates the probe capabilities +func (ts *TenantSecrets) UpdateCapabilities(probeCapabilities *sm.Probe_Capabilities) { + ts.enableProtocolSecrets = false + if probeCapabilities != nil { + ts.enableProtocolSecrets = probeCapabilities.EnableProtocolSecrets + } +} + +// secretProvider provides caching for secret values with TTL and intelligent response handling +type secretProvider struct { + tenantProvider TenantProvider + cache *cache.Cache + logger zerolog.Logger + enableProtocolSecrets bool + gsmClientFactory *GSMClientFactory +} + +// NewSecretProvider creates a new secret provider +func NewSecretProvider(tenantProvider TenantProvider, ttl time.Duration, logger zerolog.Logger) SecretProvider { + // go-cache handles cleanup automatically, so we don't need manual cleanup + // The cleanup interval is set to ttl/10 to ensure expired items are cleaned up reasonably quickly + cleanupInterval := ttl / 10 + if cleanupInterval < time.Minute { + cleanupInterval = time.Minute + } + + return &secretProvider{ + tenantProvider: tenantProvider, + cache: cache.New(ttl, cleanupInterval), + logger: logger.With().Str("component", "secret-cache").Logger(), + enableProtocolSecrets: false, // Default to false + gsmClientFactory: NewGSMClientFactory(), + } +} + +// NewSecretProviderWithCapabilities creates a new secret provider with probe capabilities +func NewSecretProviderWithCapabilities(tenantProvider TenantProvider, ttl time.Duration, logger zerolog.Logger, probeCapabilities *sm.Probe_Capabilities) SecretProvider { + enableProtocolSecrets := false + if probeCapabilities != nil { + enableProtocolSecrets = probeCapabilities.EnableProtocolSecrets + } + + // go-cache handles cleanup automatically, so we don't need manual cleanup + // The cleanup interval is set to ttl/10 to ensure expired items are cleaned up reasonably quickly + cleanupInterval := ttl / 10 + if cleanupInterval < time.Minute { + cleanupInterval = time.Minute + } + + return &secretProvider{ + tenantProvider: tenantProvider, + cache: cache.New(ttl, cleanupInterval), + logger: logger.With().Str("component", "secret-cache").Logger(), + enableProtocolSecrets: enableProtocolSecrets, + gsmClientFactory: NewGSMClientFactory(), + } +} + +// Close gracefully shuts down the secret provider +func (sp *secretProvider) Close() { + // go-cache doesn't require explicit cleanup, but we can flush the cache + sp.cache.Flush() +} + +// cacheKey creates a unique key for tenant+secret combination +func (sp *secretProvider) cacheKey(tenantID model.GlobalID, secretKey string) string { + return fmt.Sprintf("%d:%s", tenantID, secretKey) +} + +// GetSecretCredentials gets the secret store configuration for a tenant +func (sp *secretProvider) GetSecretCredentials(ctx context.Context, tenantID model.GlobalID) (*sm.SecretStore, error) { + if sp.logger.GetLevel() <= zerolog.DebugLevel { + tenantID, regionID := model.GetLocalAndRegionIDs(tenantID) + sp.logger.Debug().Int("regionID", regionID).Int64("tenantId", tenantID).Msg("getting secret credentials") + } + + tenant, err := sp.tenantProvider.GetTenant(ctx, &sm.TenantInfo{ + Id: int64(tenantID), + }) + if err != nil { + sp.logger.Warn().Err(err).Int64("tenantId", int64(tenantID)).Msg("failed to get tenant") + return nil, err + } + + return tenant.SecretStore, nil +} + +// GetSecretValue implements caching with intelligent GSM response handling +func (sp *secretProvider) GetSecretValue(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + cacheKey := sp.cacheKey(tenantID, secretKey) + + // Check cache first + if cachedValue, found := sp.cache.Get(cacheKey); found { + sp.logger.Debug(). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("secret cache hit") + return cachedValue.(string), nil + } + + sp.logger.Debug(). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("secret cache miss, fetching from GSM") + + if sp.logger.GetLevel() <= zerolog.DebugLevel { + tenantID, regionID := model.GetLocalAndRegionIDs(tenantID) + sp.logger.Debug().Int("regionID", regionID).Int64("tenantId", tenantID).Str("secretKey", secretKey).Msg("getting secret value") + } + + // Get the secret store configuration for this tenant + secretStore, err := sp.GetSecretCredentials(ctx, tenantID) + if err != nil { + return "", fmt.Errorf("failed to get secret store credentials: %w", err) + } + + if secretStore == nil { + return "", fmt.Errorf("no secret store configured for tenant %d", tenantID) + } + + // Create GSM client + client, err := sp.gsmClientFactory.CreateClient(secretStore.Url, secretStore.Token) + if err != nil { + return "", fmt.Errorf("failed to create GSM client: %w", err) + } + + // Get the decrypted secret value + resp, err := client.DecryptSecretByIdWithResponse(ctx, secretKey) + if err != nil { + // Network error or client error - leave cache unchanged + sp.logger.Warn(). + Err(err). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("network error fetching secret, leaving cache unchanged") + return "", fmt.Errorf("failed to contact GSM for secret '%s': %w", secretKey, err) + } + + // Handle different status codes + switch resp.StatusCode() { + case http.StatusOK: + // Success - update cache and return value + if resp.JSON200 == nil { + return "", fmt.Errorf("empty response from GSM for secret %s", secretKey) + } + + secretValue := resp.JSON200.Plaintext + sp.cache.Set(cacheKey, secretValue, cache.DefaultExpiration) + + sp.logger.Debug(). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("secret fetched from GSM and cached") + + return secretValue, nil + + case http.StatusNotFound: + // Secret not found - remove from cache + sp.cache.Delete(cacheKey) + + sp.logger.Warn(). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("secret not found in GSM, removed from cache") + + return "", fmt.Errorf("secret '%s' not found in GSM (404)", secretKey) + + case http.StatusUnauthorized: + // Auth issue - remove from cache (credentials may have changed) + sp.cache.Delete(cacheKey) + + sp.logger.Warn(). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("unauthorized accessing secret in GSM, removed from cache") + + return "", fmt.Errorf("unauthorized to access secret '%s' in GSM (401)", secretKey) + + default: + // 5xx or other errors - leave cache unchanged + statusCode := resp.StatusCode() + + sp.logger.Warn(). + Int("statusCode", statusCode). + Int64("tenantId", int64(tenantID)). + Str("secretKey", secretKey). + Msg("GSM returned error status, leaving cache unchanged") + + return "", fmt.Errorf("GSM returned status %d for secret '%s'", statusCode, secretKey) + } +} + +// IsProtocolSecretsEnabled returns whether protocol secrets are enabled for this probe +func (sp *secretProvider) IsProtocolSecretsEnabled() bool { + return sp.enableProtocolSecrets +} + +// UpdateCapabilities updates the probe capabilities +func (sp *secretProvider) UpdateCapabilities(probeCapabilities *sm.Probe_Capabilities) { + sp.enableProtocolSecrets = false + if probeCapabilities != nil { + sp.enableProtocolSecrets = probeCapabilities.EnableProtocolSecrets + } +} diff --git a/internal/secrets/tenant_test.go b/internal/secrets/tenant_test.go index 9e50024e3..7ccd03ccb 100644 --- a/internal/secrets/tenant_test.go +++ b/internal/secrets/tenant_test.go @@ -4,6 +4,7 @@ import ( "context" "io" "testing" + "time" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" @@ -100,3 +101,186 @@ func TestTenantSecrets_GetSecretValue(t *testing.T) { require.NoError(t, err) require.Equal(t, "", value) } + +func TestSecretProvider_GetSecretCredentials(t *testing.T) { + logger := zerolog.New(io.Discard) + + testcases := map[string]struct { + tenantProvider *mockTenantProvider + expectedStore *sm.SecretStore + expectError bool + }{ + "successful retrieval": { + tenantProvider: &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: &sm.SecretStore{ + Url: "https://secrets.example.com", + Token: "test-token", + }, + }, + }, + expectedStore: &sm.SecretStore{ + Url: "https://secrets.example.com", + Token: "test-token", + }, + expectError: false, + }, + "no secret store configured": { + tenantProvider: &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: nil, + }, + }, + expectedStore: nil, + expectError: false, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + sp := NewSecretProvider(tc.tenantProvider, time.Minute, logger) + + store, err := sp.GetSecretCredentials(context.Background(), model.GlobalID(123)) + + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedStore, store) + } + }) + } +} + +func TestSecretProvider_GetSecretValue_NoSecretStore(t *testing.T) { + logger := zerolog.New(io.Discard) + + // Mock tenant provider that returns a tenant without secret store + tenantProvider := &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: nil, + }, + } + + sp := NewSecretProvider(tenantProvider, time.Minute, logger) + + _, err := sp.GetSecretValue(context.Background(), model.GlobalID(123), "test-secret") + require.Error(t, err) + require.Contains(t, err.Error(), "no secret store configured") +} + +func TestSecretProvider_GetSecretValue_EmptyURLAndToken(t *testing.T) { + logger := zerolog.New(io.Discard) + + testCases := map[string]struct { + url string + token string + error string + }{ + "empty URL": { + url: "", + token: "test-token", + error: "GSM URL cannot be empty", + }, + "empty token": { + url: "https://test-gsm.com", + token: "", + error: "GSM token cannot be empty", + }, + "both empty": { + url: "", + token: "", + error: "GSM URL cannot be empty", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + tenantProvider := &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: &sm.SecretStore{ + Url: tc.url, + Token: tc.token, + }, + }, + } + + sp := NewSecretProvider(tenantProvider, time.Minute, logger) + + _, err := sp.GetSecretValue(context.Background(), model.GlobalID(123), "test-secret") + require.Error(t, err) + require.Contains(t, err.Error(), tc.error) + }) + } +} + +func TestSecretProvider(t *testing.T) { + logger := zerolog.New(nil).Level(zerolog.Disabled) + + t.Run("cache behavior can be observed through API", func(t *testing.T) { + tenantProvider := &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: &sm.SecretStore{ + Url: "http://test-gsm.com", + Token: "test-token", + }, + }, + } + + sp := NewSecretProvider(tenantProvider, time.Minute, logger) + + // Test that we can call the basic interface methods + store, err := sp.GetSecretCredentials(context.Background(), model.GlobalID(123)) + require.NoError(t, err) + assert.NotNil(t, store) + }) + + t.Run("cache TTL is respected", func(t *testing.T) { + ttl := 50 * time.Millisecond + tenantProvider := &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: &sm.SecretStore{ + Url: "http://test-gsm.com", + Token: "test-token", + }, + }, + } + sp := NewSecretProvider(tenantProvider, ttl, logger) + + // Test that the provider was created successfully + assert.NotNil(t, sp) + + // Test that the provider works correctly + store, err := sp.GetSecretCredentials(context.Background(), model.GlobalID(123)) + require.NoError(t, err) + assert.NotNil(t, store) + }) + + t.Run("GetSecretCredentials works correctly", func(t *testing.T) { + expectedStore := &sm.SecretStore{ + Url: "http://test-gsm.com", + Token: "test-token", + } + + tenantProvider := &mockTenantProvider{ + tenant: &sm.Tenant{ + Id: 123, + SecretStore: expectedStore, + }, + } + + sp := NewSecretProvider(tenantProvider, time.Minute, logger) + tenantID := model.GlobalID(123) + + // Should delegate to tenant provider + store, err := sp.GetSecretCredentials(context.Background(), tenantID) + require.NoError(t, err) + assert.Equal(t, expectedStore, store) + }) +} diff --git a/internal/testhelper/testhelper.go b/internal/testhelper/testhelper.go index 533545d93..b12952a59 100644 --- a/internal/testhelper/testhelper.go +++ b/internal/testhelper/testhelper.go @@ -3,6 +3,7 @@ package testhelper import ( "context" "errors" + "io" "os" "path/filepath" "runtime" @@ -33,6 +34,12 @@ func Logger(t *testing.T) zerolog.Logger { return logger.With().Caller().Timestamp().Logger() } +// NewTestLogger creates a simple logger that discards output for use in tests +// where you don't need to see the log output. +func NewTestLogger() zerolog.Logger { + return zerolog.New(io.Discard) +} + func MustReadFile(t *testing.T, filename string) []byte { t.Helper() @@ -91,6 +98,10 @@ func (n NoopSecretStore) GetSecretValue(ctx context.Context, tenantID model.Glob return "", nil } +func (n NoopSecretStore) IsProtocolSecretsEnabled() bool { + return false +} + // TestSecretStore is a test implementation of the SecretProvider interface // that returns a mock secret store with test credentials. Use this in tests // when you need to test behavior that depends on having actual secret values. @@ -115,3 +126,67 @@ func (s TestSecretStore) GetSecretValue(ctx context.Context, tenantID model.Glob // For testing purposes, return a mock secret value return "test-secret-value", nil } + +func (s TestSecretStore) IsProtocolSecretsEnabled() bool { + return true +} + +// MockSecretProvider is a flexible mock implementation of the SecretProvider interface +// that allows you to specify custom secret values and behaviors for testing. +type MockSecretProvider struct { + secrets map[string]string + getSecretValueFunc func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) + enableProtocolSecrets bool +} + +// NewMockSecretProvider creates a new MockSecretProvider with the given secrets map. +func NewMockSecretProvider(secrets map[string]string) *MockSecretProvider { + return &MockSecretProvider{ + secrets: secrets, + enableProtocolSecrets: false, // Default to false for testing + } +} + +// NewMockSecretProviderWithFunc creates a new MockSecretProvider with a custom function. +func NewMockSecretProviderWithFunc(fn func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error)) *MockSecretProvider { + return &MockSecretProvider{ + getSecretValueFunc: fn, + enableProtocolSecrets: false, // Default to false for testing + } +} + +func (m *MockSecretProvider) GetSecretCredentials(ctx context.Context, tenantID model.GlobalID) (*sm.SecretStore, error) { + return &sm.SecretStore{ + Url: "https://mock-gsm.example.com", + Token: "mock-token", + }, nil +} + +func (m *MockSecretProvider) GetSecretValue(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { + if m.getSecretValueFunc != nil { + return m.getSecretValueFunc(ctx, tenantID, secretKey) + } + + if value, exists := m.secrets[secretKey]; exists { + return value, nil + } + + return "", errors.New("secret not found") +} + +func (m *MockSecretProvider) IsProtocolSecretsEnabled() bool { + return m.enableProtocolSecrets +} + +// UpdateCapabilities updates the probe capabilities for testing +func (m *MockSecretProvider) UpdateCapabilities(probeCapabilities *sm.Probe_Capabilities) { + m.enableProtocolSecrets = false + if probeCapabilities != nil { + m.enableProtocolSecrets = probeCapabilities.EnableProtocolSecrets + } +} + +// CommonTestSetup returns commonly used test values for context, logger, and tenant ID +func CommonTestSetup() (context.Context, zerolog.Logger, model.GlobalID) { + return context.Background(), NewTestLogger(), model.GlobalID(123) +}