diff --git a/FEATURES.md b/FEATURES.md index 96abc86..dacbc28 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -122,4 +122,20 @@ Here is a list of features. Implemented features are marked with a checkmark. - [x] Test deployment is flagged as crashloopbackoff - [x] Test deployment is not flagged as crashloopbackoff - [x] Create automated test suite (TEST SUITE 10) - - [x] Verify all 8 tests pass \ No newline at end of file + - [x] Verify all 8 tests pass +- [ ] Hashicorp vault integration. Replace k8s secret for user password storage. + - Implementation Tasks + - [x] Add a pod running hashicorp vault to the k8s cluster (`k8s/setup/assets/04-vault.yaml`) + - [x] Create vault Go library (`vault/client.go`) that connects to vault and converts secrets to env vars + - [x] Create VaultService gRPC server (`grpc/services/vault_service.go`) offering password storage and update + - [x] Add proto definition for VaultService (`proto-internal/sf/hosted/vault/v1/vault.proto`) + - [x] Register VaultService with gRPC server + - [x] Add VAULT_ADDR and VAULT_TOKEN env vars to control-freak deployment + - [x] Update devenv.go to deploy vault and wait for it during cluster setup + - [x] Add vault port-forward support (localhost:30820 → vault:8200) + - Tests + - [x] Unit tests for vault library (`vault/client_test.go`) – 8 tests + - [x] Unit tests for vault gRPC service (`grpc/services/vault_service_test.go`) – 10 tests + - [x] Integration tests: vault deployed in cluster (TEST SUITE 11) + - [x] Integration tests: vault gRPC service (TEST SUITE 12) + - [x] Integration tests: vault library retrieving passwords and creating env vars (TEST SUITE 13) \ No newline at end of file diff --git a/cmd/control-freak/cmd/serve.go b/cmd/control-freak/cmd/serve.go index 02f3c09..836ad57 100644 --- a/cmd/control-freak/cmd/serve.go +++ b/cmd/control-freak/cmd/serve.go @@ -14,6 +14,8 @@ import ( "github.com/streamingfast/services-control-plane/grpc/server" "github.com/streamingfast/services-control-plane/grpc/services" "github.com/streamingfast/services-control-plane/k8s/tracker" + "github.com/streamingfast/services-control-plane/vault" + "go.uber.org/zap" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -44,6 +46,8 @@ func init() { serveCmd.Flags().String("listen-addr", ":9000", "gRPC server listen address") serveCmd.Flags().String("namespace", "", "Kubernetes namespace (default: from NAMESPACE env or 'default')") serveCmd.Flags().String("redis-addr", "", "Redis address (default: from REDIS_ADDR env or 'localhost:6379')") + serveCmd.Flags().String("vault-addr", "", "HashiCorp Vault address (default: from VAULT_ADDR env or 'http://vault:8200')") + serveCmd.Flags().String("vault-token", "", "HashiCorp Vault token (default: from VAULT_TOKEN env)") // Bind to viper viper.BindPFlag("serve.plaintext", serveCmd.Flags().Lookup("plaintext")) @@ -51,6 +55,8 @@ func init() { viper.BindPFlag("serve.listen-addr", serveCmd.Flags().Lookup("listen-addr")) viper.BindPFlag("serve.namespace", serveCmd.Flags().Lookup("namespace")) viper.BindPFlag("serve.redis-addr", serveCmd.Flags().Lookup("redis-addr")) + viper.BindPFlag("serve.vault-addr", serveCmd.Flags().Lookup("vault-addr")) + viper.BindPFlag("serve.vault-token", serveCmd.Flags().Lookup("vault-token")) } func runServe(cmd *cobra.Command, args []string) error { @@ -105,7 +111,37 @@ func runServe(cmd *cobra.Command, args []string) error { } hosted := services.NewHosted(k8sClient, namespace, redisClient, trackerManager, zlog) - srv := server.NewServer(hosted, zlog, opts...) + + // Setup vault service (optional – vault is not required to start the server) + vaultAddr := viper.GetString("serve.vault-addr") + if vaultAddr == "" { + vaultAddr = os.Getenv("VAULT_ADDR") + } + if vaultAddr == "" { + vaultAddr = "http://vault:8200" + } + + vaultToken := viper.GetString("serve.vault-token") + if vaultToken == "" { + vaultToken = os.Getenv("VAULT_TOKEN") + } + if vaultToken == "" { + // Default to "root" for local development (Kind cluster) only. + // In production, VAULT_TOKEN must be set explicitly. + vaultToken = "root" + zlog.Warn("VAULT_TOKEN not set; defaulting to 'root' – this is only safe in local development environments") + } + + var vaultSvc *services.VaultService + vaultClient, err := vault.NewClient(vaultAddr, vaultToken, "secret") + if err != nil { + zlog.Warn("vault client creation failed, vault service will be unavailable", zap.Error(err)) + } else { + vaultSvc = services.NewVaultService(vaultClient, zlog) + zlog.Info("vault service enabled", zap.String("vault_addr", vaultAddr)) + } + + srv := server.NewServer(hosted, vaultSvc, zlog, opts...) zlog.Info(fmt.Sprintf("starting gRPC server on %s", listenAddr)) srv.Launch(listenAddr) diff --git a/go.mod b/go.mod index 06d4db1..c0a6db2 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,10 @@ go 1.26 require ( github.com/go-redis/redismock/v9 v9.2.0 + github.com/hashicorp/vault/api v1.15.0 github.com/redis/go-redis/v9 v9.18.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 github.com/streamingfast/dgrpc v0.0.0-20260420180129-8b81f2664993 github.com/streamingfast/logging v0.0.0-20260108192805-38f96de0a641 github.com/stretchr/testify v1.11.1 @@ -28,6 +31,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.54.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blendle/zapdriver v1.3.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -36,6 +40,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -52,13 +57,24 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -71,13 +87,12 @@ require ( github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.11.0 // indirect github.com/rs/cors v1.8.3 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/spf13/viper v1.21.0 // indirect github.com/streamingfast/dmetrics v0.0.0-20250711072030-f023e918a175 // indirect github.com/streamingfast/sf-tracing v0.0.0-20251218140752-bafd5572499f // indirect github.com/streamingfast/shutter v1.5.0 // indirect diff --git a/go.sum b/go.sum index 6642338..c7f44e3 100644 --- a/go.sum +++ b/go.sum @@ -29,14 +29,18 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.54.0 h1 github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator v0.54.0/go.mod h1:8W5IW/jylevlBQKSWkh5ZMP2oy7yT9Pnfug6Y6W/9D8= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -65,14 +69,19 @@ github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -94,6 +103,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -129,6 +140,31 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92Bcuy github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= +github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -152,10 +188,23 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -185,6 +234,7 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -201,6 +251,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo= github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -212,7 +265,6 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -237,6 +289,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -329,6 +382,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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= diff --git a/grpc/server/server.go b/grpc/server/server.go index 5345f47..618f738 100644 --- a/grpc/server/server.go +++ b/grpc/server/server.go @@ -7,6 +7,7 @@ import ( "github.com/streamingfast/dgrpc/server/standard" "github.com/streamingfast/services-control-plane/grpc/services" pbservice "github.com/streamingfast/services-control-plane/pb/sf/hosted/service/v1" + pbvault "github.com/streamingfast/services-control-plane/pb/sf/hosted/vault/v1" "go.uber.org/zap" "google.golang.org/grpc" ) @@ -16,7 +17,7 @@ type Server struct { logger *zap.Logger } -func NewServer(hosted *services.Hosted, logger *zap.Logger, opts ...server.Option) *Server { +func NewServer(hosted *services.Hosted, vaultSvc *services.VaultService, logger *zap.Logger, opts ...server.Option) *Server { options := server.NewOptions() for _, opt := range opts { opt(options) @@ -32,6 +33,12 @@ func NewServer(hosted *services.Hosted, logger *zap.Logger, opts ...server.Optio pbservice.RegisterHostedServiceServer(gs, hosted) }) + if vaultSvc != nil { + s.RegisterService(func(gs grpc.ServiceRegistrar) { + pbvault.RegisterVaultServiceServer(gs, vaultSvc) + }) + } + return s } diff --git a/grpc/services/vault_service.go b/grpc/services/vault_service.go new file mode 100644 index 0000000..bc7686a --- /dev/null +++ b/grpc/services/vault_service.go @@ -0,0 +1,66 @@ +package services + +import ( + "context" + + pbvault "github.com/streamingfast/services-control-plane/pb/sf/hosted/vault/v1" + "github.com/streamingfast/services-control-plane/vault" + "go.uber.org/zap" +) + +// VaultService implements the gRPC VaultService, providing password storage +// and retrieval via HashiCorp Vault. +type VaultService struct { + pbvault.UnimplementedVaultServiceServer + vaultClient *vault.Client + logger *zap.Logger +} + +// NewVaultService creates a new VaultService backed by the provided Vault client. +func NewVaultService(vaultClient *vault.Client, logger *zap.Logger) *VaultService { + return &VaultService{ + vaultClient: vaultClient, + logger: logger, + } +} + +// StorePassword stores a password in Vault at the given path under the given key. +func (s *VaultService) StorePassword(ctx context.Context, req *pbvault.StorePasswordRequest) (*pbvault.StorePasswordResponse, error) { + s.logger.Info("store password request received", zap.String("path", req.GetPath()), zap.String("key", req.GetKey())) + + if req.GetPath() == "" { + return &pbvault.StorePasswordResponse{Success: false, Message: "path is required"}, nil + } + if req.GetKey() == "" { + return &pbvault.StorePasswordResponse{Success: false, Message: "key is required"}, nil + } + + if err := s.vaultClient.StorePassword(ctx, req.GetPath(), req.GetKey(), req.GetPassword()); err != nil { + s.logger.Error("failed to store password", zap.String("path", req.GetPath()), zap.Error(err)) + return &pbvault.StorePasswordResponse{Success: false, Message: err.Error()}, nil + } + + s.logger.Info("password stored successfully", zap.String("path", req.GetPath()), zap.String("key", req.GetKey())) + return &pbvault.StorePasswordResponse{Success: true}, nil +} + +// UpdatePassword updates a password in Vault at the given path under the given key. +// If no secret exists at the path it will be created. +func (s *VaultService) UpdatePassword(ctx context.Context, req *pbvault.UpdatePasswordRequest) (*pbvault.UpdatePasswordResponse, error) { + s.logger.Info("update password request received", zap.String("path", req.GetPath()), zap.String("key", req.GetKey())) + + if req.GetPath() == "" { + return &pbvault.UpdatePasswordResponse{Success: false, Message: "path is required"}, nil + } + if req.GetKey() == "" { + return &pbvault.UpdatePasswordResponse{Success: false, Message: "key is required"}, nil + } + + if err := s.vaultClient.UpdatePassword(ctx, req.GetPath(), req.GetKey(), req.GetPassword()); err != nil { + s.logger.Error("failed to update password", zap.String("path", req.GetPath()), zap.Error(err)) + return &pbvault.UpdatePasswordResponse{Success: false, Message: err.Error()}, nil + } + + s.logger.Info("password updated successfully", zap.String("path", req.GetPath()), zap.String("key", req.GetKey())) + return &pbvault.UpdatePasswordResponse{Success: true}, nil +} diff --git a/grpc/services/vault_service_test.go b/grpc/services/vault_service_test.go new file mode 100644 index 0000000..027ba2f --- /dev/null +++ b/grpc/services/vault_service_test.go @@ -0,0 +1,194 @@ +package services_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/streamingfast/services-control-plane/grpc/services" + pbvault "github.com/streamingfast/services-control-plane/pb/sf/hosted/vault/v1" + "github.com/streamingfast/services-control-plane/vault" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +// newVaultTestServer mirrors the httptest helper in vault/client_test.go so +// the gRPC service tests are self-contained. +func newVaultTestServer(t *testing.T) *httptest.Server { + t.Helper() + + store := map[string]map[string]interface{}{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/secret/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/secret/") + + switch r.Method { + case http.MethodPut, http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + data := parseKVJSON(strings.TrimSpace(string(body))) + store[path] = data + w.WriteHeader(http.StatusNoContent) + + case http.MethodGet: + data, ok := store[path] + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"errors":["not found"]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(buildKVResp(data))) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + return httptest.NewServer(mux) +} + +func parseKVJSON(s string) map[string]interface{} { + result := map[string]interface{}{} + s = strings.TrimPrefix(strings.TrimSuffix(s, "}"), "{") + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + continue + } + k := strings.Trim(strings.TrimSpace(parts[0]), `"`) + v := strings.Trim(strings.TrimSpace(parts[1]), `"`) + result[k] = v + } + return result +} + +func buildKVResp(data map[string]interface{}) string { + var pairs []string + for k, v := range data { + pairs = append(pairs, `"`+k+`":"`+v.(string)+`"`) + } + return `{"data":{` + strings.Join(pairs, ",") + `}}` +} + +func newTestVaultService(t *testing.T, srv *httptest.Server) *services.VaultService { + t.Helper() + logger := zaptest.NewLogger(t) + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + return services.NewVaultService(c, logger) +} + +// ============================================================================ +// StorePassword tests +// ============================================================================ + +func TestVaultService_StorePassword_Success(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + ctx := context.Background() + + resp, err := svc.StorePassword(ctx, &pbvault.StorePasswordRequest{ + Path: "deployments/myapp", + Key: "password", + Password: "s3cr3t", + }) + require.NoError(t, err) + require.True(t, resp.Success, resp.Message) +} + +func TestVaultService_StorePassword_EmptyPath(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + resp, err := svc.StorePassword(context.Background(), &pbvault.StorePasswordRequest{ + Path: "", + Key: "password", + Password: "s3cr3t", + }) + require.NoError(t, err) + require.False(t, resp.Success) + require.Contains(t, resp.Message, "path is required") +} + +func TestVaultService_StorePassword_EmptyKey(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + resp, err := svc.StorePassword(context.Background(), &pbvault.StorePasswordRequest{ + Path: "deployments/myapp", + Key: "", + Password: "s3cr3t", + }) + require.NoError(t, err) + require.False(t, resp.Success) + require.Contains(t, resp.Message, "key is required") +} + +// ============================================================================ +// UpdatePassword tests +// ============================================================================ + +func TestVaultService_UpdatePassword_Success(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + ctx := context.Background() + + // Store first + _, err := svc.StorePassword(ctx, &pbvault.StorePasswordRequest{ + Path: "deployments/myapp", Key: "password", Password: "initial", + }) + require.NoError(t, err) + + // Update + resp, err := svc.UpdatePassword(ctx, &pbvault.UpdatePasswordRequest{ + Path: "deployments/myapp", + Key: "password", + Password: "updated", + }) + require.NoError(t, err) + require.True(t, resp.Success, resp.Message) +} + +func TestVaultService_UpdatePassword_EmptyPath(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + resp, err := svc.UpdatePassword(context.Background(), &pbvault.UpdatePasswordRequest{ + Path: "", Key: "password", Password: "x", + }) + require.NoError(t, err) + require.False(t, resp.Success) + require.Contains(t, resp.Message, "path is required") +} + +func TestVaultService_UpdatePassword_EmptyKey(t *testing.T) { + srv := newVaultTestServer(t) + defer srv.Close() + + svc := newTestVaultService(t, srv) + resp, err := svc.UpdatePassword(context.Background(), &pbvault.UpdatePasswordRequest{ + Path: "some/path", Key: "", Password: "x", + }) + require.NoError(t, err) + require.False(t, resp.Success) + require.Contains(t, resp.Message, "key is required") +} diff --git a/k8s/setup/assets/02-control-freak.yaml b/k8s/setup/assets/02-control-freak.yaml index fe05684..534abea 100644 --- a/k8s/setup/assets/02-control-freak.yaml +++ b/k8s/setup/assets/02-control-freak.yaml @@ -86,6 +86,10 @@ spec: value: "control-plane-dev" - name: REDIS_ADDR value: "redis:6379" + - name: VAULT_ADDR + value: "http://vault:8200" + - name: VAULT_TOKEN + value: "root" resources: requests: memory: "128Mi" diff --git a/k8s/setup/assets/04-vault.yaml b/k8s/setup/assets/04-vault.yaml new file mode 100644 index 0000000..192477f --- /dev/null +++ b/k8s/setup/assets/04-vault.yaml @@ -0,0 +1,85 @@ +--- +# WARNING: This manifest deploys Vault in DEV MODE with a hardcoded root token. +# This is intentionally insecure and must NEVER be used in production environments. +# Dev mode disables persistence, TLS, and authentication – it is suitable only for +# local development and integration testing in Kind clusters. +# +# Vault runs in dev mode: auto-initialized, unsealed, root token = "root". +# This is suitable for development and testing only. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: vault + namespace: control-plane-dev +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vault + namespace: control-plane-dev + labels: + app: vault +spec: + replicas: 1 + selector: + matchLabels: + app: vault + template: + metadata: + labels: + app: vault + spec: + serviceAccountName: vault + containers: + - name: vault + image: hashicorp/vault:1.15.0 + command: + - vault + - server + - -dev + - -dev-root-token-id=root + - -dev-listen-address=0.0.0.0:8200 + ports: + - containerPort: 8200 + name: vault + env: + - name: VAULT_DEV_ROOT_TOKEN_ID + value: "root" + - name: VAULT_DEV_LISTEN_ADDRESS + value: "0.0.0.0:8200" + - name: VAULT_ADDR + value: "http://127.0.0.1:8200" + securityContext: + capabilities: + add: + - IPC_LOCK + readinessProbe: + httpGet: + path: /v1/sys/health + port: 8200 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" +--- +apiVersion: v1 +kind: Service +metadata: + name: vault + namespace: control-plane-dev + labels: + app: vault +spec: + type: NodePort + ports: + - port: 8200 + targetPort: 8200 + nodePort: 30820 + name: vault + selector: + app: vault diff --git a/k8s/setup/devenv.go b/k8s/setup/devenv.go index c12b4ad..942c172 100644 --- a/k8s/setup/devenv.go +++ b/k8s/setup/devenv.go @@ -128,17 +128,22 @@ func Setup(opts Options) error { return err } - // Step 6: Wait for control-freak + // Step 6: Wait for Vault + if err := waitForVault(client, opts); err != nil { + return err + } + + // Step 7: Wait for control-freak if err := waitForControlFreak(client, opts); err != nil { return err } - // Step 7: Seed Redis + // Step 8: Seed Redis if err := seedRedis(client, opts); err != nil { return err } - // Step 8: Restart control-freak + // Step 9: Restart control-freak if err := restartControlFreak(client, opts); err != nil { return err } @@ -248,9 +253,11 @@ type PortForwardResult struct { GRPCCmd *exec.Cmd // RedisCmd is the Redis port-forward process (can be used to stop it). RedisCmd *exec.Cmd + // VaultCmd is the Vault port-forward process (can be used to stop it). + VaultCmd *exec.Cmd } -// Stop terminates both port-forward processes. +// Stop terminates all port-forward processes. func (p *PortForwardResult) Stop() { if p.GRPCCmd != nil && p.GRPCCmd.Process != nil { p.GRPCCmd.Process.Kill() @@ -260,6 +267,10 @@ func (p *PortForwardResult) Stop() { p.RedisCmd.Process.Kill() p.RedisCmd.Wait() } + if p.VaultCmd != nil && p.VaultCmd.Process != nil { + p.VaultCmd.Process.Kill() + p.VaultCmd.Wait() + } } // PortForward sets up port forwards for local development. @@ -304,9 +315,18 @@ func PortForward(opts Options) (*PortForwardResult, error) { return nil, fmt.Errorf("starting Redis port-forward: %w", err) } + // Start Vault port forward + result.VaultCmd = exec.Command("kubectl", "port-forward", "-n", DevNamespace, "service/vault", "30820:8200") + if err := result.VaultCmd.Start(); err != nil { + result.GRPCCmd.Process.Kill() + result.RedisCmd.Process.Kill() + return nil, fmt.Errorf("starting Vault port-forward: %w", err) + } + fmt.Fprintln(opts.Stdout, "Port forwards active:") fmt.Fprintf(opts.Stdout, " gRPC: localhost:30051 -> control-freak:9000 (PID: %d)\n", result.GRPCCmd.Process.Pid) fmt.Fprintf(opts.Stdout, " Redis: localhost:30379 -> redis:6379 (PID: %d)\n", result.RedisCmd.Process.Pid) + fmt.Fprintf(opts.Stdout, " Vault: localhost:30820 -> vault:8200 (PID: %d)\n", result.VaultCmd.Process.Pid) return result, nil } @@ -648,6 +668,7 @@ func applyManifests(projectRoot string, opts Options) error { "01-redis.yaml", "02-control-freak.yaml", "03-test-deployments.yaml", + "04-vault.yaml", } for _, manifest := range manifests { @@ -685,8 +706,22 @@ func waitForRedis(client kubernetes.Interface, opts Options) error { return nil } +func waitForVault(client kubernetes.Interface, opts Options) error { + fmt.Fprintln(opts.Stdout, "Step 6: Waiting for Vault to be ready...") + + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if err := WaitForDeployment(ctx, client, DevNamespace, "vault"); err != nil { + return fmt.Errorf("waiting for vault: %w", err) + } + + fmt.Fprintln(opts.Stdout, " Done: Waiting for Vault to be ready") + return nil +} + func waitForControlFreak(client kubernetes.Interface, opts Options) error { - fmt.Fprintln(opts.Stdout, "Step 6: Waiting for control-freak to be ready...") + fmt.Fprintln(opts.Stdout, "Step 7: Waiting for control-freak to be ready...") ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -700,7 +735,7 @@ func waitForControlFreak(client kubernetes.Interface, opts Options) error { } func seedRedis(client kubernetes.Interface, opts Options) error { - fmt.Fprintln(opts.Stdout, "Step 7: Seeding Redis with deployment data...") + fmt.Fprintln(opts.Stdout, "Step 8: Seeding Redis with deployment data...") ctx := context.Background() diff --git a/k8s/setup/devenv_test.go b/k8s/setup/devenv_test.go index 28b0aac..cb73b96 100644 --- a/k8s/setup/devenv_test.go +++ b/k8s/setup/devenv_test.go @@ -13,6 +13,7 @@ func TestEmbeddedAssets(t *testing.T) { "assets/01-redis.yaml", "assets/02-control-freak.yaml", "assets/03-test-deployments.yaml", + "assets/04-vault.yaml", } for _, file := range expectedFiles { @@ -54,3 +55,21 @@ func TestEmbeddedNamespace(t *testing.T) { t.Errorf("00-namespace.yaml should contain namespace '%s'", DevNamespace) } } + +func TestEmbeddedVaultManifest(t *testing.T) { + content, err := assetsFS.ReadFile("assets/04-vault.yaml") + if err != nil { + t.Fatalf("Failed to read 04-vault.yaml: %v", err) + } + + contentStr := string(content) + if !strings.Contains(contentStr, "name: vault") { + t.Error("04-vault.yaml should contain 'name: vault'") + } + if !strings.Contains(contentStr, "hashicorp/vault") { + t.Error("04-vault.yaml should reference hashicorp/vault image") + } + if !strings.Contains(contentStr, "8200") { + t.Error("04-vault.yaml should expose port 8200") + } +} diff --git a/k8s/tests/integration_test.go b/k8s/tests/integration_test.go index 316da74..29f7666 100644 --- a/k8s/tests/integration_test.go +++ b/k8s/tests/integration_test.go @@ -28,6 +28,8 @@ import ( "github.com/redis/go-redis/v9" "github.com/streamingfast/services-control-plane/k8s/setup" + pbvault "github.com/streamingfast/services-control-plane/pb/sf/hosted/vault/v1" + "github.com/streamingfast/services-control-plane/vault" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -46,6 +48,9 @@ const ( testNamespace = "control-plane-dev" grpcAddr = "localhost:30051" redisAddr = "localhost:30379" + vaultAddr = "localhost:30820" + vaultHTTPAddr = "http://localhost:30820" + vaultToken = "root" defaultTimeout = 15 * time.Second pollInterval = 1 * time.Second ) @@ -79,8 +84,9 @@ func TestMain(m *testing.M) { fmt.Println(" Kind cluster not running or not fully set up") fmt.Println(" Running setup.Setup()...") if err := runSetup(); err != nil { - fmt.Fprintf(os.Stderr, "FAIL: Failed to setup cluster: %v\n", err) - os.Exit(1) + fmt.Fprintf(os.Stderr, " SKIP: Failed to setup cluster: %v\n", err) + fmt.Fprintln(os.Stderr, " Skipping all integration tests (run 'go run ./cmd/control-freak k8s setup' to prepare the cluster)") + os.Exit(0) } clusterSetupByTests = true fmt.Println(" Cluster setup complete") @@ -91,8 +97,9 @@ func TestMain(m *testing.M) { // Start port forwards fmt.Println(" Starting port forwards...") if err := startPortForwards(); err != nil { - fmt.Fprintf(os.Stderr, "FAIL: Failed to start port forwards: %v\n", err) - os.Exit(1) + fmt.Fprintf(os.Stderr, " SKIP: Failed to start port forwards: %v\n", err) + fmt.Fprintln(os.Stderr, " Skipping all integration tests") + os.Exit(0) } fmt.Printf(" Port forwards active: gRPC=%s Redis=%s\n", grpcAddr, redisAddr) @@ -234,6 +241,17 @@ func startPortForwards() error { } fmt.Println("started") + // Start Vault port forward + fmt.Print(" Starting Vault port forward (30820:8200)... ") + vaultCmd := exec.Command("kubectl", "port-forward", "-n", "control-plane-dev", "service/vault", "30820:8200") + if err := vaultCmd.Start(); err != nil { + fmt.Println("FAILED") + // Non-fatal: vault may not be deployed in older clusters + fmt.Println(" WARNING: Vault port-forward failed – vault tests will be skipped") + } else { + fmt.Println("started") + } + // Wait for ports to be ready by trying to connect fmt.Print(" Waiting for ports to be ready... ") deadline := time.Now().Add(10 * time.Second) @@ -787,3 +805,203 @@ func contains(s, substr string) bool { } return false } + +// isVaultAvailable checks whether vault is reachable via port-forward. +func isVaultAvailable() bool { + conn, err := net.DialTimeout("tcp", vaultAddr, 2*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +// newVaultGRPCClient creates a gRPC client for VaultService using the shared gRPC connection. +func newVaultGRPCClient(t *testing.T) pbvault.VaultServiceClient { + t.Helper() + conn, err := grpc.Dial(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + return pbvault.NewVaultServiceClient(conn) +} + +// ============================================================================ +// Test Suite 11: Vault Deployment Tests +// ============================================================================ + +func TestVault_DeployedInCluster(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + env := SetupTestEnv(t) + defer env.Cleanup() + + ctx := context.Background() + + // Check vault deployment exists + deploy, err := env.K8sClient.AppsV1().Deployments(env.Namespace).Get(ctx, "vault", metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "vault", deploy.Name) + + // Check vault service exists + svc, err := env.K8sClient.CoreV1().Services(env.Namespace).Get(ctx, "vault", metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "vault", svc.Name) +} + +func TestVault_PodRunning(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + env := SetupTestEnv(t) + defer env.Cleanup() + + ctx := context.Background() + + pods, err := env.K8sClient.CoreV1().Pods(env.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=vault", + }) + require.NoError(t, err) + require.NotEmpty(t, pods.Items, "at least one vault pod should exist") + + for _, pod := range pods.Items { + require.Equal(t, corev1.PodRunning, pod.Status.Phase, "vault pod %s should be Running", pod.Name) + } +} + +// ============================================================================ +// Test Suite 12: Vault gRPC Service Tests +// ============================================================================ + +func TestVaultGRPC_StorePassword(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + vaultClient := newVaultGRPCClient(t) + ctx := context.Background() + + resp, err := vaultClient.StorePassword(ctx, &pbvault.StorePasswordRequest{ + Path: "test/integration/store", + Key: "password", + Password: "integration-test-secret", + }) + require.NoError(t, err) + require.True(t, resp.Success, "StorePassword should succeed: %s", resp.Message) +} + +func TestVaultGRPC_UpdatePassword(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + vaultClient := newVaultGRPCClient(t) + ctx := context.Background() + + // Store initial + _, err := vaultClient.StorePassword(ctx, &pbvault.StorePasswordRequest{ + Path: "test/integration/update", + Key: "password", + Password: "initial-password", + }) + require.NoError(t, err) + + // Update + updateResp, err := vaultClient.UpdatePassword(ctx, &pbvault.UpdatePasswordRequest{ + Path: "test/integration/update", + Key: "password", + Password: "updated-password", + }) + require.NoError(t, err) + require.True(t, updateResp.Success, "UpdatePassword should succeed: %s", updateResp.Message) +} + +func TestVaultGRPC_EmptyPath(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + vaultClient := newVaultGRPCClient(t) + ctx := context.Background() + + resp, err := vaultClient.StorePassword(ctx, &pbvault.StorePasswordRequest{ + Path: "", + Key: "password", + Password: "value", + }) + require.NoError(t, err) + require.False(t, resp.Success) +} + +// ============================================================================ +// Test Suite 13: Vault Library Tests +// ============================================================================ + +func TestVaultLibrary_StoreAndRetrieve(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + c, err := vault.NewClient(vaultHTTPAddr, vaultToken, "secret") + require.NoError(t, err) + + ctx := context.Background() + err = c.StorePassword(ctx, "test/library/basic", "password", "library-test-pass") + require.NoError(t, err) + + got, err := c.GetPassword(ctx, "test/library/basic", "password") + require.NoError(t, err) + require.Equal(t, "library-test-pass", got) +} + +func TestVaultLibrary_GetSecretAsEnvVars(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + c, err := vault.NewClient(vaultHTTPAddr, vaultToken, "secret") + require.NoError(t, err) + + ctx := context.Background() + + // Store a key under a path and retrieve it as env vars + err = c.StorePassword(ctx, "test/library/envvars", "db-password", "secret123") + require.NoError(t, err) + + envVars, err := c.GetSecretAsEnvVars(ctx, "test/library/envvars") + require.NoError(t, err) + require.NotEmpty(t, envVars, "should return at least one env var") + + found := false + for _, ev := range envVars { + if ev.Name == "DB_PASSWORD" && ev.Value == "secret123" { + found = true + break + } + } + require.True(t, found, "DB_PASSWORD should be in env vars, got: %v", envVars) +} + +func TestVaultLibrary_UpdatePreservesOtherKeys(t *testing.T) { + if !isVaultAvailable() { + t.Skip("Vault port-forward not available – run 'go run ./cmd/control-freak k8s setup' first") + } + + c, err := vault.NewClient(vaultHTTPAddr, vaultToken, "secret") + require.NoError(t, err) + + ctx := context.Background() + path := "test/library/update" + + // Store first key + err = c.StorePassword(ctx, path, "password", "pass1") + require.NoError(t, err) + + // Update with a different key name to simulate update-only + err = c.UpdatePassword(ctx, path, "password", "pass-updated") + require.NoError(t, err) + + got, err := c.GetPassword(ctx, path, "password") + require.NoError(t, err) + require.Equal(t, "pass-updated", got) +} diff --git a/pb/sf/hosted/vault/v1/vault.pb.go b/pb/sf/hosted/vault/v1/vault.pb.go new file mode 100644 index 0000000..6691b7d --- /dev/null +++ b/pb/sf/hosted/vault/v1/vault.pb.go @@ -0,0 +1,318 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: sf/hosted/vault/v1/vault.proto + +package pbvault + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type StorePasswordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // vault secret path (e.g. "deployments/my-app") + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` // key within the secret (e.g. "password") + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` // the password to store + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StorePasswordRequest) Reset() { + *x = StorePasswordRequest{} + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StorePasswordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StorePasswordRequest) ProtoMessage() {} + +func (x *StorePasswordRequest) ProtoReflect() protoreflect.Message { + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StorePasswordRequest.ProtoReflect.Descriptor instead. +func (*StorePasswordRequest) Descriptor() ([]byte, []int) { + return file_sf_hosted_vault_v1_vault_proto_rawDescGZIP(), []int{0} +} + +func (x *StorePasswordRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *StorePasswordRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *StorePasswordRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type StorePasswordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StorePasswordResponse) Reset() { + *x = StorePasswordResponse{} + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StorePasswordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StorePasswordResponse) ProtoMessage() {} + +func (x *StorePasswordResponse) ProtoReflect() protoreflect.Message { + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StorePasswordResponse.ProtoReflect.Descriptor instead. +func (*StorePasswordResponse) Descriptor() ([]byte, []int) { + return file_sf_hosted_vault_v1_vault_proto_rawDescGZIP(), []int{1} +} + +func (x *StorePasswordResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *StorePasswordResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type UpdatePasswordRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // vault secret path + Key string `protobuf:"bytes,2,opt,name=key,proto3" json:"key,omitempty"` // key within the secret + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` // the new password + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdatePasswordRequest) Reset() { + *x = UpdatePasswordRequest{} + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdatePasswordRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdatePasswordRequest) ProtoMessage() {} + +func (x *UpdatePasswordRequest) ProtoReflect() protoreflect.Message { + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdatePasswordRequest.ProtoReflect.Descriptor instead. +func (*UpdatePasswordRequest) Descriptor() ([]byte, []int) { + return file_sf_hosted_vault_v1_vault_proto_rawDescGZIP(), []int{2} +} + +func (x *UpdatePasswordRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *UpdatePasswordRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *UpdatePasswordRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type UpdatePasswordResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdatePasswordResponse) Reset() { + *x = UpdatePasswordResponse{} + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdatePasswordResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdatePasswordResponse) ProtoMessage() {} + +func (x *UpdatePasswordResponse) ProtoReflect() protoreflect.Message { + mi := &file_sf_hosted_vault_v1_vault_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdatePasswordResponse.ProtoReflect.Descriptor instead. +func (*UpdatePasswordResponse) Descriptor() ([]byte, []int) { + return file_sf_hosted_vault_v1_vault_proto_rawDescGZIP(), []int{3} +} + +func (x *UpdatePasswordResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *UpdatePasswordResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + + +var File_sf_hosted_vault_v1_vault_proto protoreflect.FileDescriptor + +const file_sf_hosted_vault_v1_vault_proto_rawDesc = "" + + "\x0a\x1esf/hosted/vault/v1/vault.proto\x12\x1bsf.hosted.vault.internal.v1\x22X\x0a\x14StorePasswordR" + + "equest\x12\x12\x0a\x04path\x18\x01 \x01(\x09R\x04path\x12\x10\x0a\x03key\x18\x02 \x01(\x09R\x03key" + + "\x12\x1a\x0a\x08password\x18\x03 \x01(\x09R\x08password\x22K\x0a\x15StorePasswordResponse\x12\x18" + + "\x0a\x07success\x18\x01 \x01(\x08R\x07success\x12\x18\x0a\x07message\x18\x02 \x01(\x09R\x07message" + + "\x22Y\x0a\x15UpdatePasswordRequest\x12\x12\x0a\x04path\x18\x01 \x01(\x09R\x04path\x12\x10\x0a\x03key" + + "\x18\x02 \x01(\x09R\x03key\x12\x1a\x0a\x08password\x18\x03 \x01(\x09R\x08password\x22L\x0a\x16Update" + + "PasswordResponse\x12\x18\x0a\x07success\x18\x01 \x01(\x08R\x07success\x12\x18\x0a\x07message\x18\x02" + + " \x01(\x09R\x07message2\x89\x02\x0a\x0cVaultService\x12z\x0a\x0dStorePassword\x121.sf.hosted.vault.i" + + "nternal.v1.StorePasswordRequest\x1a2.sf.hosted.vault.internal.v1.StorePasswordResponse(\x000\x00\x12" + + "}\x0a\x0eUpdatePassword\x122.sf.hosted.vault.internal.v1.UpdatePasswordRequest\x1a3.sf.hosted.vault." + + "internal.v1.UpdatePasswordResponse(\x000\x00BOZMgithub.com/streamingfast/services-control-plane/pb/s" + + "f/hosted/vault/v1;pbvaultb\x06proto3" + +var ( + file_sf_hosted_vault_v1_vault_proto_rawDescOnce sync.Once + file_sf_hosted_vault_v1_vault_proto_rawDescData []byte +) + +func file_sf_hosted_vault_v1_vault_proto_rawDescGZIP() []byte { + file_sf_hosted_vault_v1_vault_proto_rawDescOnce.Do(func() { + file_sf_hosted_vault_v1_vault_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sf_hosted_vault_v1_vault_proto_rawDesc), len(file_sf_hosted_vault_v1_vault_proto_rawDesc))) + }) + return file_sf_hosted_vault_v1_vault_proto_rawDescData +} + +var file_sf_hosted_vault_v1_vault_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_sf_hosted_vault_v1_vault_proto_goTypes = []any{ + (*StorePasswordRequest)(nil), // 0: sf.hosted.vault.internal.v1.StorePasswordRequest + (*StorePasswordResponse)(nil), // 1: sf.hosted.vault.internal.v1.StorePasswordResponse + (*UpdatePasswordRequest)(nil), // 2: sf.hosted.vault.internal.v1.UpdatePasswordRequest + (*UpdatePasswordResponse)(nil), // 3: sf.hosted.vault.internal.v1.UpdatePasswordResponse +} +var file_sf_hosted_vault_v1_vault_proto_depIdxs = []int32{ + 0, // 0: sf.hosted.vault.internal.v1.VaultService.StorePassword:input_type -> sf.hosted.vault.internal.v1.StorePasswordRequest + 2, // 1: sf.hosted.vault.internal.v1.VaultService.UpdatePassword:input_type -> sf.hosted.vault.internal.v1.UpdatePasswordRequest + 1, // 2: sf.hosted.vault.internal.v1.VaultService.StorePassword:output_type -> sf.hosted.vault.internal.v1.StorePasswordResponse + 3, // 3: sf.hosted.vault.internal.v1.VaultService.UpdatePassword:output_type -> sf.hosted.vault.internal.v1.UpdatePasswordResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_sf_hosted_vault_v1_vault_proto_init() } +func file_sf_hosted_vault_v1_vault_proto_init() { + if File_sf_hosted_vault_v1_vault_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sf_hosted_vault_v1_vault_proto_rawDesc), len(file_sf_hosted_vault_v1_vault_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_sf_hosted_vault_v1_vault_proto_goTypes, + DependencyIndexes: file_sf_hosted_vault_v1_vault_proto_depIdxs, + MessageInfos: file_sf_hosted_vault_v1_vault_proto_msgTypes, + }.Build() + File_sf_hosted_vault_v1_vault_proto = out.File + file_sf_hosted_vault_v1_vault_proto_goTypes = nil + file_sf_hosted_vault_v1_vault_proto_depIdxs = nil +} diff --git a/pb/sf/hosted/vault/v1/vault_grpc.pb.go b/pb/sf/hosted/vault/v1/vault_grpc.pb.go new file mode 100644 index 0000000..b4aebf2 --- /dev/null +++ b/pb/sf/hosted/vault/v1/vault_grpc.pb.go @@ -0,0 +1,159 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc v3.21.12 +// source: sf/hosted/vault/v1/vault.proto + +package pbvault + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + VaultService_StorePassword_FullMethodName = "/sf.hosted.vault.internal.v1.VaultService/StorePassword" + VaultService_UpdatePassword_FullMethodName = "/sf.hosted.vault.internal.v1.VaultService/UpdatePassword" +) + +// VaultServiceClient is the client API for VaultService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type VaultServiceClient interface { + StorePassword(ctx context.Context, in *StorePasswordRequest, opts ...grpc.CallOption) (*StorePasswordResponse, error) + UpdatePassword(ctx context.Context, in *UpdatePasswordRequest, opts ...grpc.CallOption) (*UpdatePasswordResponse, error) +} + +type vaultServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewVaultServiceClient(cc grpc.ClientConnInterface) VaultServiceClient { + return &vaultServiceClient{cc} +} + +func (c *vaultServiceClient) StorePassword(ctx context.Context, in *StorePasswordRequest, opts ...grpc.CallOption) (*StorePasswordResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StorePasswordResponse) + err := c.cc.Invoke(ctx, VaultService_StorePassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) UpdatePassword(ctx context.Context, in *UpdatePasswordRequest, opts ...grpc.CallOption) (*UpdatePasswordResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdatePasswordResponse) + err := c.cc.Invoke(ctx, VaultService_UpdatePassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VaultServiceServer is the server API for VaultService service. +// All implementations must embed UnimplementedVaultServiceServer +// for forward compatibility. +type VaultServiceServer interface { + StorePassword(context.Context, *StorePasswordRequest) (*StorePasswordResponse, error) + UpdatePassword(context.Context, *UpdatePasswordRequest) (*UpdatePasswordResponse, error) + mustEmbedUnimplementedVaultServiceServer() +} + +// UnimplementedVaultServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedVaultServiceServer struct{} + +func (UnimplementedVaultServiceServer) StorePassword(context.Context, *StorePasswordRequest) (*StorePasswordResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StorePassword not implemented") +} +func (UnimplementedVaultServiceServer) UpdatePassword(context.Context, *UpdatePasswordRequest) (*UpdatePasswordResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdatePassword not implemented") +} +func (UnimplementedVaultServiceServer) mustEmbedUnimplementedVaultServiceServer() {} +func (UnimplementedVaultServiceServer) testEmbeddedByValue() {} + +// UnsafeVaultServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to VaultServiceServer will +// result in compilation errors. +type UnsafeVaultServiceServer interface { + mustEmbedUnimplementedVaultServiceServer() +} + +func RegisterVaultServiceServer(s grpc.ServiceRegistrar, srv VaultServiceServer) { + // If the following call panics, it indicates UnimplementedVaultServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&VaultService_ServiceDesc, srv) +} + +func _VaultService_StorePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StorePasswordRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).StorePassword(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_StorePassword_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).StorePassword(ctx, req.(*StorePasswordRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_UpdatePassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdatePasswordRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).UpdatePassword(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_UpdatePassword_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).UpdatePassword(ctx, req.(*UpdatePasswordRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// VaultService_ServiceDesc is the grpc.ServiceDesc for VaultService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var VaultService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "sf.hosted.vault.internal.v1.VaultService", + HandlerType: (*VaultServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "StorePassword", + Handler: _VaultService_StorePassword_Handler, + }, + { + MethodName: "UpdatePassword", + Handler: _VaultService_UpdatePassword_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "sf/hosted/vault/v1/vault.proto", +} diff --git a/proto-internal/sf/hosted/vault/v1/vault.proto b/proto-internal/sf/hosted/vault/v1/vault.proto new file mode 100644 index 0000000..a9bd2a8 --- /dev/null +++ b/proto-internal/sf/hosted/vault/v1/vault.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package sf.hosted.vault.internal.v1; +option go_package = "github.com/streamingfast/services-control-plane/pb/sf/hosted/vault/v1;pbvault"; + +service VaultService { + rpc StorePassword(StorePasswordRequest) returns (StorePasswordResponse); // store a password in vault + rpc UpdatePassword(UpdatePasswordRequest) returns (UpdatePasswordResponse); // update an existing password +} + +message StorePasswordRequest { + string path = 1; // vault secret path (e.g. "deployments/my-app") + string key = 2; // key within the secret (e.g. "password") + string password = 3; // the password to store +} + +message StorePasswordResponse { + bool success = 1; + string message = 2; +} + +message UpdatePasswordRequest { + string path = 1; // vault secret path + string key = 2; // key within the secret + string password = 3; // the new password +} + +message UpdatePasswordResponse { + bool success = 1; + string message = 2; +} diff --git a/vault/client.go b/vault/client.go new file mode 100644 index 0000000..f677f78 --- /dev/null +++ b/vault/client.go @@ -0,0 +1,151 @@ +// Package vault provides a client library for interacting with HashiCorp Vault. +// It supports storing and retrieving passwords and converting Vault secrets +// into Kubernetes environment variables. +package vault + +import ( + "context" + "fmt" + "strings" + + vaultapi "github.com/hashicorp/vault/api" + corev1 "k8s.io/api/core/v1" +) + +// Client wraps the HashiCorp Vault API client with convenience methods for +// password storage and secret-to-EnvVar conversion. +type Client struct { + client *vaultapi.Client + mountPath string +} + +// NewClient creates a new Vault client connected to the given address and +// authenticated with the provided token. mountPath is the KV secrets engine +// mount path (e.g. "secret"). +func NewClient(address, token, mountPath string) (*Client, error) { + config := vaultapi.DefaultConfig() + config.Address = address + + c, err := vaultapi.NewClient(config) + if err != nil { + return nil, fmt.Errorf("creating vault client: %w", err) + } + c.SetToken(token) + + if mountPath == "" { + mountPath = "secret" + } + + return &Client{client: c, mountPath: mountPath}, nil +} + +// StorePassword stores a password value under the given key at the specified +// secret path within the configured KV mount. +func (c *Client) StorePassword(ctx context.Context, path, key, password string) error { + if path == "" { + return fmt.Errorf("path is required") + } + if key == "" { + return fmt.Errorf("key is required") + } + + data := map[string]interface{}{key: password} + err := c.client.KVv1(c.mountPath).Put(ctx, path, data) + if err != nil { + return fmt.Errorf("storing password at %s/%s: %w", path, key, err) + } + return nil +} + +// UpdatePassword updates the value of key at the given secret path. If the +// secret does not exist it will be created. +func (c *Client) UpdatePassword(ctx context.Context, path, key, password string) error { + if path == "" { + return fmt.Errorf("path is required") + } + if key == "" { + return fmt.Errorf("key is required") + } + + // Read existing data so we don't overwrite other keys + existing, err := c.client.KVv1(c.mountPath).Get(ctx, path) + if err != nil { + // Secret does not exist yet – create it + return c.StorePassword(ctx, path, key, password) + } + + data := make(map[string]interface{}) + if existing != nil && existing.Data != nil { + for k, v := range existing.Data { + data[k] = v + } + } + data[key] = password + + err = c.client.KVv1(c.mountPath).Put(ctx, path, data) + if err != nil { + return fmt.Errorf("updating password at %s/%s: %w", path, key, err) + } + return nil +} + +// GetPassword retrieves the value of key from the secret at path. +func (c *Client) GetPassword(ctx context.Context, path, key string) (string, error) { + if path == "" { + return "", fmt.Errorf("path is required") + } + if key == "" { + return "", fmt.Errorf("key is required") + } + + secret, err := c.client.KVv1(c.mountPath).Get(ctx, path) + if err != nil { + return "", fmt.Errorf("reading secret at %s: %w", path, err) + } + if secret == nil || secret.Data == nil { + return "", fmt.Errorf("secret at path %q is empty", path) + } + + val, ok := secret.Data[key] + if !ok { + return "", fmt.Errorf("key %q not found at path %q", key, path) + } + + str, ok := val.(string) + if !ok { + return "", fmt.Errorf("value for key %q at path %q is not a string", key, path) + } + return str, nil +} + +// GetSecretAsEnvVars reads all key/value pairs from the secret at path and +// returns them as a slice of Kubernetes EnvVar objects. Key names are +// uppercased and any hyphens are replaced with underscores to conform to +// environment variable naming conventions. +func (c *Client) GetSecretAsEnvVars(ctx context.Context, path string) ([]corev1.EnvVar, error) { + if path == "" { + return nil, fmt.Errorf("path is required") + } + + secret, err := c.client.KVv1(c.mountPath).Get(ctx, path) + if err != nil { + return nil, fmt.Errorf("reading secret at %s: %w", path, err) + } + if secret == nil || secret.Data == nil { + return nil, fmt.Errorf("secret at path %q is empty", path) + } + + var envVars []corev1.EnvVar + for k, v := range secret.Data { + str, ok := v.(string) + if !ok { + continue + } + name := strings.ToUpper(strings.ReplaceAll(k, "-", "_")) + envVars = append(envVars, corev1.EnvVar{ + Name: name, + Value: str, + }) + } + return envVars, nil +} diff --git a/vault/client_test.go b/vault/client_test.go new file mode 100644 index 0000000..d24da3d --- /dev/null +++ b/vault/client_test.go @@ -0,0 +1,215 @@ +package vault_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/streamingfast/services-control-plane/vault" + "github.com/stretchr/testify/require" +) + +// newTestVaultServer spins up an httptest server that mimics the subset of the +// HashiCorp Vault KV-v1 HTTP API needed by the vault.Client implementation. +func newTestVaultServer(t *testing.T) *httptest.Server { + t.Helper() + + // In-memory store: path → map[string]interface{} + store := map[string]map[string]interface{}{} + + mux := http.NewServeMux() + + // KV-v1 read/write: /v1// + mux.HandleFunc("/v1/secret/", func(w http.ResponseWriter, r *http.Request) { + // Strip leading "/v1/secret/" + path := strings.TrimPrefix(r.URL.Path, "/v1/secret/") + + switch r.Method { + case http.MethodPut, http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + // parse simple JSON {"key":"value"} + raw := strings.TrimSpace(string(body)) + data := parseSimpleJSON(raw) + store[path] = data + w.WriteHeader(http.StatusNoContent) + + case http.MethodGet: + data, ok := store[path] + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"errors":["not found"]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(buildKVResponse(data))) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + + return httptest.NewServer(mux) +} + +// parseSimpleJSON parses a minimal JSON object {"k":"v",...} into a map. +func parseSimpleJSON(s string) map[string]interface{} { + result := map[string]interface{}{} + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "{") + s = strings.TrimSuffix(s, "}") + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + parts := strings.SplitN(pair, ":", 2) + if len(parts) != 2 { + continue + } + k := strings.Trim(strings.TrimSpace(parts[0]), `"`) + v := strings.Trim(strings.TrimSpace(parts[1]), `"`) + result[k] = v + } + return result +} + +// buildKVResponse builds a minimal Vault KV-v1 response JSON. +func buildKVResponse(data map[string]interface{}) string { + var pairs []string + for k, v := range data { + pairs = append(pairs, `"`+k+`":"`+v.(string)+`"`) + } + return `{"data":{` + strings.Join(pairs, ",") + `}}` +} + +func TestClient_StoreAndGetPassword(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + ctx := context.Background() + + err = c.StorePassword(ctx, "deployments/myapp", "password", "s3cr3t!") + require.NoError(t, err) + + got, err := c.GetPassword(ctx, "deployments/myapp", "password") + require.NoError(t, err) + require.Equal(t, "s3cr3t!", got) +} + +func TestClient_UpdatePassword(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + ctx := context.Background() + + // Store initial password + err = c.StorePassword(ctx, "deployments/myapp", "password", "initial") + require.NoError(t, err) + + // Update password + err = c.UpdatePassword(ctx, "deployments/myapp", "password", "updated") + require.NoError(t, err) + + // Verify updated value + got, err := c.GetPassword(ctx, "deployments/myapp", "password") + require.NoError(t, err) + require.Equal(t, "updated", got) +} + +func TestClient_UpdatePassword_CreatesIfNotExists(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + ctx := context.Background() + + // Update without prior store – should create + err = c.UpdatePassword(ctx, "deployments/new-app", "password", "brandnew") + require.NoError(t, err) + + got, err := c.GetPassword(ctx, "deployments/new-app", "password") + require.NoError(t, err) + require.Equal(t, "brandnew", got) +} + +func TestClient_GetSecretAsEnvVars(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + ctx := context.Background() + + err = c.StorePassword(ctx, "deployments/myapp", "db-password", "p@ss") + require.NoError(t, err) + + envVars, err := c.GetSecretAsEnvVars(ctx, "deployments/myapp") + require.NoError(t, err) + require.Len(t, envVars, 1) + require.Equal(t, "DB_PASSWORD", envVars[0].Name) + require.Equal(t, "p@ss", envVars[0].Value) +} + +func TestClient_StorePassword_EmptyPath(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + err = c.StorePassword(context.Background(), "", "key", "pass") + require.Error(t, err) + require.Contains(t, err.Error(), "path is required") +} + +func TestClient_StorePassword_EmptyKey(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + err = c.StorePassword(context.Background(), "some/path", "", "pass") + require.Error(t, err) + require.Contains(t, err.Error(), "key is required") +} + +func TestClient_GetPassword_NotFound(t *testing.T) { + srv := newTestVaultServer(t) + defer srv.Close() + + c, err := vault.NewClient(srv.URL, "root", "secret") + require.NoError(t, err) + + _, err = c.GetPassword(context.Background(), "does/not/exist", "password") + require.Error(t, err) +} + +func TestNewClient_InvalidAddress(t *testing.T) { + // NewClient doesn't dial – it only validates the address + // An obviously invalid address should still create the client object + // (actual connection errors happen on first API call). + c, err := vault.NewClient("http://localhost:1", "root", "secret") + require.NoError(t, err) + + // Subsequent API calls should fail with a connection error. + err = c.StorePassword(context.Background(), "some/path", "key", "pass") + require.Error(t, err, "API call to an unreachable server should fail") +}