diff --git a/config/secrets.go b/config/secrets.go new file mode 100644 index 00000000..617bc833 --- /dev/null +++ b/config/secrets.go @@ -0,0 +1,22 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package config + +import ( + "github.com/prometheus/common/secrets" +) + +func init() { + // Intermediate step in the migration to the new secrets API. + secrets.SetVisibilityPolicy(func() bool { return MarshalSecretValue }) +} diff --git a/go.mod b/go.mod index fbface64..2819aa9f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/julienschmidt/httprouter v1.3.0 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f + github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v2 v2.4.3 @@ -25,7 +26,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.20.4 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 00000000..fd224b2f --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,18 @@ +# Secret Management + +The `secrets` package provides a unified way to handle secrets within configuration files for Prometheus and its ecosystem components. It allows secrets to be specified inline, loaded from files, or fetched from other sources through a pluggable provider mechanism. + +See the rendered [GoDoc here](https://pkg.go.dev/github.com/prometheus/common/secrets) if on GitHub. + +## How to Use + +Using the `secrets` package involves three main steps: defining your configuration struct, initializing the secret manager, and accessing the secret values. Refer to the [package example GoDoc](https://pkg.go.dev/github.com/prometheus/common/secrets#example-package). + + +## Built-in Providers + +The `secrets` package comes with two built-in providers: `inline` and `file`. For more details, please refer to the [GoDoc](https://pkg.go.dev/github.com/prometheus/common/secrets#pkg-variables). + +## Custom Providers + +You can extend the functionality by creating your own custom secret providers. For a detailed guide on creating custom providers, please refer to the [GoDoc for the `Provider` and `ProviderConfig` interfaces](https://pkg.go.dev/github.com/prometheus/common/secrets#Provider). diff --git a/secrets/doc.go b/secrets/doc.go new file mode 100644 index 00000000..254e0488 --- /dev/null +++ b/secrets/doc.go @@ -0,0 +1,18 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package secrets provides a unified way to handle secrets within +// configuration files for Prometheus and its ecosystem components. It allows +// secrets to be specified inline, loaded from files, or fetched from other +// sources through a pluggable provider mechanism. +package secrets diff --git a/secrets/example_test.go b/secrets/example_test.go new file mode 100644 index 00000000..0365eb26 --- /dev/null +++ b/secrets/example_test.go @@ -0,0 +1,87 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets_test + +import ( + "context" + "fmt" + "os" + + "github.com/prometheus/client_golang/prometheus" + "go.yaml.in/yaml/v2" + + "github.com/prometheus/common/promslog" + "github.com/prometheus/common/secrets" +) + +func Example() { + // A Prometheus registry is needed to register the secret manager's metrics. + promRegisterer := prometheus.NewRegistry() + + // Create a temporary file to simulate a file-based secret (e.g., Kubernetes mount). + passwordFile, err := os.CreateTemp("", "password_secret") + if err != nil { + panic(err) + } + defer os.Remove(passwordFile.Name()) + + if _, err := passwordFile.WriteString("my_super_secret_password"); err != nil { + passwordFile.Close() + panic(err) + } + passwordFile.Close() + + // In your configuration struct, use the `secrets.Field` type for any fields + // that should contain secrets. + type MyConfig struct { + APIKey secrets.Field `yaml:"api_key"` + Password secrets.Field `yaml:"password"` + } + + // Users can then provide secrets in their YAML configuration file. + // We inject the temporary file path created above. + configData := []byte(fmt.Sprintf(` +api_key: "my_super_secret_api_key" +password: + file: + path: %s +`, passwordFile.Name())) + + var cfg MyConfig + if err := yaml.Unmarshal(configData, &cfg); err != nil { + panic(fmt.Errorf("error unmarshaling config: %w", err)) + } + + // Create a secret manager. This discovers and manages all Fields in cfg. + // The manager will handle refreshing secrets in the background. + manager, err := secrets.NewManager(promslog.NewNopLogger(), promRegisterer, secrets.Providers, &cfg) + if err != nil { + panic(fmt.Errorf("error creating secret manager: %w", err)) + } + + // Start the manager's background refresh loop. + manager.Start(context.Background()) + defer manager.Stop() + + // Access the secret values. + apiKey := cfg.APIKey.Value() + password := cfg.Password.Value() + + fmt.Printf("API Key: %s\n", apiKey) + fmt.Printf("Password: %s\n", password) + + // Output: + // API Key: my_super_secret_api_key + // Password: my_super_secret_password +} diff --git a/secrets/field.go b/secrets/field.go new file mode 100644 index 00000000..686b3dd1 --- /dev/null +++ b/secrets/field.go @@ -0,0 +1,258 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "encoding/json" + "fmt" + "sync/atomic" + "time" + + "go.yaml.in/yaml/v2" +) + +// PolicyFunc returns true if secrets should be printed (insecure), +// and false if they should be scrubbed (secure). +type PolicyFunc func() bool + +var ( + currentPolicy PolicyFunc = func() bool { return true } + + // policyInitialized tracks if the policy has been explicitly set. + policyInitialized atomic.Bool +) + +// SetVisibilityPolicy sets the global function that determines if secrets +// should be printed or scrubbed. +// +// This is designed to be called exactly ONCE, typically by the +// prometheus/common/config package during initialization. +func SetVisibilityPolicy(p PolicyFunc) { + if !policyInitialized.CompareAndSwap(false, true) { + panic("prometheus/common/secrets: duplicate initialization of secret visibility policy.\n" + + "\tReason: The secret scrubbing policy is a global singleton that must be consistent across the application.\n" + + "\tExpected Owner: Only 'prometheus/common/config' should initialize this policy.\n", + ) + } + + currentPolicy = p +} + +// Field is a field containing a secret. +// +// In a configuration struct, use secrets.Field for any fields that should +// contain secrets. +// +// secrets.Field handles YAML unmarshaling and can be either a plain string +// (for inline secrets) or a structure for a specific secret provider. +// +// For example: +// +// type MyConfig struct { +// APIKey secrets.Field `yaml:"api_key"` +// Password secrets.Field `yaml:"password"` +// } +// +// In the YAML file, the secrets can be configured as follows: +// +// api_key: "my_super_secret_api_key" +// password: +// file: +// path: /path/to/password.txt +type Field struct { + rawConfig any + state *fieldState +} + +type fieldState struct { + path string + manager *Manager + settings FieldSettings + providerName string + config ProviderConfig + fetched time.Time + value string +} + +type FieldSettings struct { + RefreshInterval time.Duration `yaml:"refreshInterval,omitempty"` +} + +func (fs *fieldState) id() string { + providerID := fs.path + if provider, ok := fs.config.(ProviderConfigID); ok { + providerID = provider.ID() + } + return fmt.Sprintf("%s>%s", fs.providerName, providerID) +} + +func (s Field) String() string { + if s.state == nil { + return "Field{UNPARSED}" + } + return fmt.Sprintf("Field[%s]{Provider: %s}", s.state.path, s.state.providerName) +} + +// MarshalYAML implements the yaml.Marshaler interface for Field. +func (s Field) MarshalYAML() (interface{}, error) { + printRaw := currentPolicy() + if !printRaw { + return "", nil + } + return s.rawConfig, nil +} + +// MarshalJSON implements the json.Marshaler interface for Field. +func (s Field) MarshalJSON() ([]byte, error) { + data, err := s.MarshalYAML() + if err != nil { + return nil, err + } + return json.Marshal(data) +} + +type mapType = map[string]any + +// splitProviderAndSettings separates provider-specific configuration from the generic SecretField settings. +func splitProviderAndSettings(provReg *ProviderRegistry, baseMap mapType) (providerName string, providerData interface{}, settingsMap mapType, err error) { + settingsMap = make(mapType) + + for k, v := range baseMap { + // Check if the key corresponds to a registered provider. + if _, err := provReg.Get(k); err == nil { + if providerName != "" { + // A provider has already been found, which is an error. + return "", nil, nil, fmt.Errorf("secret must contain exactly one provider type, but multiple were found: %s, %s", providerName, k) + } + providerName = k + providerData = v + } else { + // If it's not a provider key, treat it as a setting. + settingsMap[k] = v + } + } + + if providerName == "" { + // Marshal the map back to YAML for a readable error message. + yamlBytes, err := yaml.Marshal(baseMap) + if err != nil { + // Fallback to the original format if marshalling fails for some reason. + return "", nil, nil, fmt.Errorf("no valid secret provider found in configuration: %v", baseMap) + } + return "", nil, nil, fmt.Errorf("no valid secret provider found in configuration:\n%s", string(yamlBytes)) + } + + return providerName, providerData, settingsMap, nil +} + +// convertConfig takes a yaml-parsed any and unmarshals it into a typed struct. +// It achieves this by first marshalling the input to YAML and then unmarshalling +// it into the target struct. +func convertConfig[T any](source any, target T) error { + bytes, err := yaml.Marshal(source) + if err != nil { + return fmt.Errorf("failed to re-marshal config: %w", err) + } + if err := yaml.Unmarshal(bytes, target); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + return nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface for Field. +func (s *Field) UnmarshalYAML(unmarshal func(interface{}) error) error { + return unmarshal(&s.rawConfig) +} + +func (s *Field) parseRawConfig(reg *ProviderRegistry, path string) (*fieldState, error) { + var plainSecret string + if err := convertConfig(s.rawConfig, &plainSecret); err == nil { + return &fieldState{ + path: path, + providerName: "inline", + config: &InlineProviderConfig{ + secret: plainSecret, + }, + value: plainSecret, + }, nil + } + + var baseMap mapType + if err := convertConfig(s.rawConfig, &baseMap); err != nil { + return nil, err + } + + providerName, providerConfigData, settingsMap, err := splitProviderAndSettings(reg, baseMap) + if err != nil { + return nil, err + } + + providerConfig, err := reg.Get(providerName) + if err != nil { + return nil, err + } + + if err := convertConfig(providerConfigData, providerConfig); err != nil { + return nil, fmt.Errorf("failed to unmarshal into %s provider: %w", providerName, err) + } + var settings FieldSettings + if err := convertConfig(settingsMap, &settings); err != nil { + return nil, fmt.Errorf("failed to unmarshal secret field settings: %w", err) + } + + return &fieldState{ + path: path, + providerName: providerName, + config: providerConfig, + settings: settings, + }, nil +} + +// Value returns the secret value. +// +// This method will panic if the Field has not been discovered by a Manager. +// To avoid this, ensure that NewManager is called with a pointer to the +// configuration struct containing the Field. +func (s *Field) Value() string { + if s.state == nil { + panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?") + } + return s.state.value +} + +func (s *Field) ValueOrEmpty() string { + if s == nil { + return "" + } + return s.Value() +} + +func (s *Field) WasFetched() bool { + if s.state == nil { + panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?") + } + return !s.state.fetched.IsZero() +} + +// TriggerRefresh signals the Manager to refresh the secret. +// +// This method will panic if the Field has not been discovered by a Manager. +// To avoid this, ensure that NewManager is called with a pointer to the +// configuration struct containing the Field. +func (s *Field) TriggerRefresh() { + if s.state == nil { + panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?") + } + s.state.manager.triggerRefresh(s) +} diff --git a/secrets/field_test.go b/secrets/field_test.go new file mode 100644 index 00000000..8b62ff0f --- /dev/null +++ b/secrets/field_test.go @@ -0,0 +1,187 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v2" +) + +func TestField_ParseRawConfig(t *testing.T) { + tests := []struct { + name string + yaml string + expectProviderName string + expectProviderConfig ProviderConfig + expectSettings FieldSettings + expectedID string + expectErr string + }{ + { + name: "Unmarshal plain string into InlineProvider", + yaml: `my_secret_value`, + expectProviderName: "inline", + expectProviderConfig: &InlineProviderConfig{ + secret: "my_secret_value", + }, + expectedID: "inline>test_path", + }, + { + name: "Unmarshal file provider", + yaml: ` +file: + path: /path/to/secret +`, + expectProviderName: "file", + expectProviderConfig: &FileProviderConfig{ + Path: "/path/to/secret", + }, + expectedID: "file>/path/to/secret", + }, + { + name: "Unmarshal file provider with settings", + yaml: ` +file: + path: /path/to/secret +refreshInterval: 5m +`, + expectProviderName: "file", + expectProviderConfig: &FileProviderConfig{ + Path: "/path/to/secret", + }, + expectSettings: FieldSettings{ + RefreshInterval: 5 * time.Minute, + }, + expectedID: "file>/path/to/secret", + }, + { + name: "Error on multiple providers", + yaml: ` +file: + path: /path/to/secret +inline: another_secret +`, + expectErr: "secret must contain exactly one provider type, but multiple were found: ", + }, + { + name: "Error on unknown provider", + yaml: ` +moon_secret_manager: + moon_phase: full +`, + expectErr: `no valid secret provider found in configuration:`, + }, + { + name: "Error on invalid provider config", + yaml: ` +file: + path: [ "this", "should", "be", "a", "string" ] +`, + expectErr: "failed to unmarshal into file provider", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sf Field + err := yaml.Unmarshal([]byte(tt.yaml), &sf) + require.NoError(t, err) + + state, err := sf.parseRawConfig(Providers, "test_path") + + if tt.expectErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectProviderName, state.providerName) + assert.Equal(t, tt.expectedID, state.id()) + assert.Equal(t, tt.expectProviderConfig, state.config) + assert.Equal(t, tt.expectSettings, state.settings) + } + }) + } +} + +func TestField_MarshalYAML(t *testing.T) { + tests := []struct { + name string + field Field + policy PolicyFunc + expectedYAML string + }{ + { + name: "Should marshal raw config when policy is true", + field: Field{ + rawConfig: map[string]interface{}{ + "file": map[string]interface{}{ + "path": "/path/to/token", + }, + }, + }, + policy: func() bool { return true }, + expectedYAML: "file:\n path: /path/to/token\n", + }, + { + name: "Should marshal as when policy is false", + field: Field{ + rawConfig: "supersecret", + }, + policy: func() bool { return false }, + expectedYAML: "\n", + }, + { + name: "Should marshal inline secret when policy is true", + field: Field{ + rawConfig: "inline_secret", + }, + policy: func() bool { return true }, + expectedYAML: "inline_secret\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + currentPolicy = tt.policy + defer func() { + currentPolicy = func() bool { return true } + policyInitialized.Store(false) + }() + + b, err := yaml.Marshal(tt.field) + require.NoError(t, err) + assert.Equal(t, tt.expectedYAML, string(b)) + }) + } +} + +func TestSecretField_MarshalJSON(t *testing.T) { + // JSON marshaling is just a wrapper around YAML marshaling, so a simple test is sufficient. + sf := Field{ + rawConfig: map[string]interface{}{ + "file": map[string]interface{}{ + "path": "/path/to/token", + }, + }, + } + b, err := json.Marshal(sf) + require.NoError(t, err) + expected := `{"file":{"path":"/path/to/token"}}` + assert.JSONEq(t, expected, string(b)) +} diff --git a/secrets/internal_providers.go b/secrets/internal_providers.go new file mode 100644 index 00000000..1188d174 --- /dev/null +++ b/secrets/internal_providers.go @@ -0,0 +1,89 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" + "os" +) + +type fileProvider struct { + path string +} + +func (fp *fileProvider) FetchSecret(_ context.Context) (string, error) { + content, err := os.ReadFile(fp.path) + if err != nil { + return "", err + } + return string(content), nil +} + +// FileProviderConfig is the configuration for the `file` provider. +// +// The `file` provider reads the secret from a file. +// To use the `file` provider, configure it in your YAML file as follows: +// +// password: +// file: +// path: /path/to/password.txt +type FileProviderConfig struct { + Path string `yaml:"path" json:"path"` +} + +func (fpc *FileProviderConfig) NewProvider() (Provider, error) { + return &fileProvider{path: fpc.Path}, nil +} + +func (fpc *FileProviderConfig) Clone() ProviderConfig { + return &FileProviderConfig{Path: fpc.Path} +} + +func (fpc *FileProviderConfig) ID() string { + return fpc.Path +} + +type inlineProvider struct { + secret string +} + +func (ip *inlineProvider) FetchSecret(_ context.Context) (string, error) { + return ip.secret, nil +} + +// InlineProviderConfig is the configuration for the `inline` provider. +// +// The `inline` provider uses a secret that is specified directly in the +// configuration file. This is the default provider if a plain string is +// provided for a secret field. +// +// To use the `inline` provider, configure it in your YAML file as follows: +// +// api_key: "my_super_secret_api_key" +type InlineProviderConfig struct { + secret string +} + +func (ipc *InlineProviderConfig) NewProvider() (Provider, error) { + return &inlineProvider{secret: ipc.secret}, nil +} + +func (ipc *InlineProviderConfig) Clone() ProviderConfig { + return &InlineProviderConfig{secret: ipc.secret} +} + +func init() { + Providers.Register("inline", &InlineProviderConfig{}) + Providers.Register("file", &FileProviderConfig{}) +} diff --git a/secrets/internal_providers_test.go b/secrets/internal_providers_test.go new file mode 100644 index 00000000..8bbc6bd8 --- /dev/null +++ b/secrets/internal_providers_test.go @@ -0,0 +1,71 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileProviderConfig(t *testing.T) { + ctx := context.Background() + secretContent := "my-super-secret-password" + tempDir := t.TempDir() + secretFile := filepath.Join(tempDir, "secret.txt") + + err := os.WriteFile(secretFile, []byte(secretContent), 0o600) + require.NoError(t, err) + + fpc := &FileProviderConfig{Path: secretFile} + fp, err := fpc.NewProvider() + require.NoError(t, err) + + t.Run("FetchSecret_Success", func(t *testing.T) { + content, err := fp.FetchSecret(ctx) + require.NoError(t, err) + assert.Equal(t, secretContent, content) + }) + + t.Run("FetchSecret_NotFound", func(t *testing.T) { + badFPC := &FileProviderConfig{Path: filepath.Join(tempDir, "non-existent.txt")} + badFP, err := badFPC.NewProvider() + require.NoError(t, err) + _, err = badFP.FetchSecret(ctx) + require.Error(t, err) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("Id", func(t *testing.T) { + assert.Equal(t, secretFile, fpc.ID()) + }) +} + +func TestInlineProviderConfig(t *testing.T) { + ctx := context.Background() + secretContent := "my-inline-secret" + ipc := &InlineProviderConfig{secret: secretContent} + ip, err := ipc.NewProvider() + require.NoError(t, err) + + t.Run("FetchSecret", func(t *testing.T) { + content, err := ip.FetchSecret(ctx) + require.NoError(t, err) + assert.Equal(t, secretContent, content) + }) +} diff --git a/secrets/lifecycle.go b/secrets/lifecycle.go new file mode 100644 index 00000000..b7616e82 --- /dev/null +++ b/secrets/lifecycle.go @@ -0,0 +1,91 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package secrets + +import ( + "errors" + "fmt" +) + +// SecretPreparer is implemented by structs that need to mutate or validate +// their state before the Secret Manager attempts to fetch values. +// +// Returning an error here is FATAL. It indicates the configuration +// is structurally unsound and the Manager should exit immediately. +type SecretPreparer interface { + PrepareSecrets(h Populator) error +} + +// SecretVerifier is implemented by structs that need to validate +// the data *after* the Secret Manager has populated the fields. +// +// This may be called multiple times (e.g. on rotation or retry). +// Returning an error here indicates the *current values* are invalid, +// potentially causing this specific config to be ignored/dropped until fixed. +// Commented out as behaviour is not stable yet. +// type SecretVerifier interface { +// VerifySecrets() error +// } + +type Populator struct { + manager *Manager + fields fieldResults[*Field] +} + +func runPreppers(m *Manager, cfg any) error { + fields, err := findFields[*Field](cfg) + if err != nil { + return err + } + h := Populator{ + manager: m, + fields: fields, + } + + preppers, err := findFields[SecretPreparer](cfg) + if err != nil { + return err + } + + errs := make([]error, 0) + for i := len(preppers.ordered) - 1; i >= 0; i-- { + prep := preppers.ordered[i] + if err := prep.PrepareSecrets(h); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) + +} + +func (h Populator) FromFieldOrFile( + secretField **Field, filePath string) error { + if h.manager == nil || h.manager.populating == false { + panic("FromFieldOrFile can only be called during PrepareSecrets; the manager has not yet started populating") + } + if count(secretField != nil && *secretField != nil, len(filePath) > 0) == 2 { + path := h.fields.paths[*secretField] + return fmt.Errorf("at most one of %s & %s_file must be configured", path, path) + } + if len(filePath) > 0 { + cfg := map[string]any{ + "file": FileProviderConfig{ + Path: filePath, + }, + } + (*secretField) = &Field{} + return convertConfig(cfg, &(*secretField).rawConfig) + } + + return nil +} diff --git a/secrets/lifecycle_test.go b/secrets/lifecycle_test.go new file mode 100644 index 00000000..2a261763 --- /dev/null +++ b/secrets/lifecycle_test.go @@ -0,0 +1,46 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type PreparerConfig struct { + SecretField *Field `yaml:"secret_field"` + SecretFieldFile string `yaml:"secret_field_file"` +} + +func (c *PreparerConfig) PrepareSecrets(h Populator) error { + return h.FromFieldOrFile(&c.SecretField, c.SecretFieldFile) +} + +func TestManager_PrepareSecrets(t *testing.T) { + secretFile := filepath.Join(t.TempDir(), "secret.txt") + require.NoError(t, os.WriteFile(secretFile, []byte("file_secret"), 0o600)) + + contents := fmt.Sprintf(` +secret_field_file: %q +`, secretFile) + + _, cfg, _ := setupManagerTest[PreparerConfig](t, contents, &mockProvider{}) + + assert.Equalf(t, "file_secret", cfg.SecretField.Value(), "expected to get secret field from file.") +} diff --git a/secrets/manager.go b/secrets/manager.go new file mode 100644 index 00000000..2aa85985 --- /dev/null +++ b/secrets/manager.go @@ -0,0 +1,433 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/common/promslog" +) + +const ( + // newFetchTimeout governs the default maximum time a fetch can take during manager creation. + newFetchTimeout = 1 * time.Second + // defaultPopulateFetchTimeout governs the default maximum time a fetch can block during .PopulateConfig() calls. + defaultPopulateFetchTimeout = 0 * time.Millisecond + // fetchTimeout governs the maximum time a single fetch attempt can take. + fetchTimeout = 5 * time.Minute + // fetchInitialBackoff is the initial backoff duration for refetching a secret after a failure. + fetchInitialBackoff = 1 * time.Second + // fetchMaxBackoff is the maximum backoff duration for retrying a failed fetch. + fetchMaxBackoff = 2 * time.Minute + + // the default refresh interval for secrets. + defaultRefreshInterval = time.Hour + + // Prometheus secret states. + stateSuccess float64 = 0 + stateStale float64 = 1 + stateError float64 = 2 + stateInitializing float64 = 3 +) + +// Manager discovers, manages, and refreshes all Field instances within a +// given configuration. +// +// After unmarshaling a configuration file into a struct, a new Manager should +// be created by passing a pointer to the configuration struct to NewManager. +// The Manager will discover all fields of type Field, parse their +// configuration, and prepare them for fetching. +// +// The Manager should be started by calling the Start method, which launches a +// background goroutine to periodically refresh the secrets. The Stop method +// should be called to stop the background refresh. +// +// Before accessing any secret values, the SecretsReady method should be +// called to ensure that all secrets have been successfully fetched at least +// once. +type Manager struct { + mtx sync.RWMutex + secrets map[string]*managedSecret + populating bool + providers *ProviderRegistry + refreshC chan struct{} + cancel context.CancelFunc + wg sync.WaitGroup + registerFetchTimeout time.Duration + logger *slog.Logger + // Prometheus metrics + lastSuccessfulFetch *prometheus.GaugeVec + secretState *prometheus.GaugeVec + fetchSuccessTotal *prometheus.CounterVec + fetchFailuresTotal *prometheus.CounterVec + fetchDuration *prometheus.HistogramVec +} + +type managedSecret struct { + mtx sync.RWMutex + secret string + fetched time.Time + fetchCancel context.CancelFunc + refreshInterval time.Duration + refreshRequested bool + metricLabels prometheus.Labels + provider Provider +} + +// NewManager discovers all Field instances within the provided config +// structure using reflection and registers them with this manager. +// +// It also registers Prometheus metrics to monitor the state of the secrets. +// The following metrics are available, all labeled with `provider` and `secret_id`: +// +// - `prometheus_remote_secret_last_successful_fetch_seconds`: (Gauge) The Unix +// timestamp of the last successful secret fetch. +// - `prometheus_remote_secret_state`: (Gauge) Describes the current state of a +// remotely fetched secret (0=success, 1=stale, 2=error, 3=initializing). +// - `prometheus_remote_secret_fetch_success_total`: (Counter) Total number of +// successful secret fetches. +// - `prometheus_remote_secret_fetch_failures_total`: (Counter) Total number +// of failed secret fetches. +// - `prometheus_remote_secret_fetch_duration_seconds`: (Histogram) Duration of +// secret fetch attempts. +func NewManager(logger *slog.Logger, r prometheus.Registerer, providers *ProviderRegistry, config interface{}) (*Manager, error) { + if logger == nil { + logger = promslog.NewNopLogger() + } + manager := &Manager{ + secrets: make(map[string]*managedSecret), + refreshC: make(chan struct{}, 1), + logger: logger, + // blocks waiting for up to newFetchTimeout. + registerFetchTimeout: newFetchTimeout, + providers: providers, + } + promslog.NewNopLogger() + manager.registerMetrics(r) + if err := manager.PopulateConfig(config); err != nil { + return nil, err + } + manager.registerFetchTimeout = defaultPopulateFetchTimeout + return manager, nil +} + +func (m *Manager) registerMetrics(r prometheus.Registerer) { + labels := []string{"provider", "secret_id"} + + m.lastSuccessfulFetch = promauto.With(r).NewGaugeVec( + prometheus.GaugeOpts{ + Name: "prometheus_remote_secret_last_successful_fetch_seconds", + Help: "The unix timestamp of the last successful secret fetch.", + }, + labels, + ) + m.secretState = promauto.With(r).NewGaugeVec( + prometheus.GaugeOpts{ + Name: "prometheus_remote_secret_state", + Help: "Describes the current state of a remotely fetched secret (0=success, 1=stale, 2=error, 3=initializing).", + }, + labels, + ) + m.fetchSuccessTotal = promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Name: "prometheus_remote_secret_fetch_success_total", + Help: "Total number of successful secret fetches.", + }, + labels, + ) + m.fetchFailuresTotal = promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Name: "prometheus_remote_secret_fetch_failures_total", + Help: "Total number of failed secret fetches.", + }, + labels, + ) + + m.fetchDuration = promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "prometheus_remote_secret_fetch_duration_seconds", + Help: "Duration of secret fetch attempts.", + Buckets: prometheus.DefBuckets, + }, + labels, + ) +} + +func (m *Manager) registerSecret(path string, s *Field) error { + if s.state != nil { + return fmt.Errorf("secrets.Field %s has already been populated.", path) + } + + state, err := s.parseRawConfig(m.providers, path) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + state.manager = m + + labels := prometheus.Labels{ + "provider": state.providerName, + "secret_id": state.id(), + } + + if state.settings.RefreshInterval == 0 { + state.settings.RefreshInterval = defaultRefreshInterval + } + + mSecret, exists := m.secrets[state.id()] + if !exists { + provider, err := state.config.NewProvider() + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + secret, fetched := "", time.Time{} + if m.registerFetchTimeout > 0 { + ctx, cancel := context.WithTimeout(context.Background(), m.registerFetchTimeout) + secret, err = provider.FetchSecret(ctx) + cancel() + if err == nil { + fetched = time.Now() + } + } + mSecret = &managedSecret{ + refreshInterval: state.settings.RefreshInterval, + metricLabels: labels, + provider: provider, + fetched: fetched, + secret: secret, + } + m.secrets[state.id()] = mSecret + m.secretState.With(labels).Set(stateInitializing) + m.fetchSuccessTotal.With(labels).Add(0) + m.fetchFailuresTotal.With(labels).Add(0) + } + mSecret.mtx.Lock() + defer mSecret.mtx.Unlock() + + mSecret.refreshInterval = min( + mSecret.refreshInterval, + state.settings.RefreshInterval) + state.value = mSecret.secret + state.fetched = mSecret.fetched + s.state = state + return nil +} + +func (m *Manager) PopulateConfig(config interface{}) error { + m.mtx.Lock() + m.populating = true + defer func() { + m.populating = false + m.mtx.Unlock() + }() + + if err := runPreppers(m, config); err != nil { + return err + } + + droppedSecrets := make(map[string]struct{}, len(m.secrets)) + for secretId := range m.secrets { + droppedSecrets[secretId] = struct{}{} + } + fields, err := findFields[*Field](config) + if err != nil { + return err + } + errs := make([]error, 0) + for field, path := range fields.paths { + if err := m.registerSecret(path, field); err != nil { + errs = append(errs, err) + } else if _, ok := droppedSecrets[field.state.id()]; ok { + delete(droppedSecrets, field.state.id()) + } + } + if err := errors.Join(errs...); err != nil { + return err + } + for secretId := range droppedSecrets { + secret := m.secrets[secretId] + secret.mtx.Lock() + if secret.fetchCancel != nil { + secret.fetchCancel() + } + m.lastSuccessfulFetch.Delete(secret.metricLabels) + m.secretState.Delete(secret.metricLabels) + m.fetchSuccessTotal.Delete(secret.metricLabels) + m.fetchFailuresTotal.Delete(secret.metricLabels) + m.fetchDuration.Delete(secret.metricLabels) + secret.mtx.Unlock() + delete(m.secrets, secretId) + + } + return nil +} + +func (m *Manager) triggerRefresh(s *Field) { + m.mtx.RLock() + defer m.mtx.RUnlock() + ms, ok := m.secrets[s.state.id()] + + if !ok { + m.logger.Warn("triggerRefresh: secret with not found; refresh not triggered", "secret_id", s.state.id()) + return + } + + ms.mtx.Lock() + defer ms.mtx.Unlock() + + ms.refreshRequested = true + select { + case m.refreshC <- struct{}{}: + default: + // a refresh is already pending, do nothing + } +} + +// Start launches the background goroutine that periodically fetches secrets. +func (m *Manager) Start(ctx context.Context) { + ctx, cancel := context.WithCancel(ctx) + + m.wg.Add(1) + go func() { + defer m.wg.Done() + m.fetchSecretsLoop(ctx) + }() + + m.cancel = cancel +} + +// Stop terminates the background secret fetching goroutine. +func (m *Manager) Stop() { + m.cancel() + m.wg.Wait() +} + +// fetchSecretsLoop is a long-running goroutine that periodically fetches secrets. +func (m *Manager) fetchSecretsLoop(ctx context.Context) { + timer := time.NewTimer(time.Duration(0)) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + case <-m.refreshC: + if !timer.Stop() { + <-timer.C + } + } + m.mtx.RLock() + // Create a list of secrets to check to avoid holding the lock during fetch operations. + secretsToCheck := make([]*managedSecret, 0, len(m.secrets)) + for _, secret := range m.secrets { + secretsToCheck = append(secretsToCheck, secret) + } + m.mtx.RUnlock() + + waitTime := 5 * time.Minute + + for _, ms := range secretsToCheck { + ms.mtx.Lock() + + timeToRefresh := time.Until(ms.fetched.Add(ms.refreshInterval)) + refreshNeeded := ms.refreshRequested || timeToRefresh < 0 + waitTime = min(waitTime, ms.refreshInterval) + + if ms.fetchCancel != nil { + ms.mtx.Unlock() + continue + } + + if !refreshNeeded { + ms.mtx.Unlock() + if timeToRefresh > 0 { + waitTime = min(waitTime, timeToRefresh) + } + continue + } + var fetchCtx context.Context + fetchCtx, ms.fetchCancel = context.WithCancel(ctx) + ms.mtx.Unlock() + + go m.fetchAndStoreSecret(fetchCtx, ms) + } + timer.Reset(waitTime) + } +} + +// fetchAndStoreSecret performs a single secret fetch, including retry logic with exponential backoff. +// It is robust against hangs in the underlying provider's FetchSecret method. +func (m *Manager) fetchAndStoreSecret(ctx context.Context, ms *managedSecret) { + var newSecret string + var err error + ms.mtx.RLock() + provider := ms.provider + hasBeenFetchedBefore := !ms.fetched.IsZero() + ms.mtx.RUnlock() + backoff := fetchInitialBackoff + var startFetch, endFetch time.Time + for { + + startFetch = time.Now() + fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + endFetch = time.Now() + + newSecret, err = provider.FetchSecret(fetchCtx) + cancel() + + if err == nil { + break // Success + } + + ms.mtx.RLock() + m.fetchFailuresTotal.With(ms.metricLabels).Inc() + if hasBeenFetchedBefore { + m.secretState.With(ms.metricLabels).Set(stateStale) + } else { + m.secretState.With(ms.metricLabels).Set(stateError) + } + ms.mtx.RUnlock() + + select { + case <-time.After(backoff): + backoff = min(fetchMaxBackoff, backoff*2) + case <-ctx.Done(): + ms.mtx.Lock() + ms.fetchCancel = nil + ms.refreshRequested = false + ms.mtx.Unlock() + return + } + } + ms.mtx.Lock() + + if ms.fetchCancel != nil { + m.fetchSuccessTotal.With(ms.metricLabels).Inc() + m.fetchDuration.With(ms.metricLabels).Observe(endFetch.Sub(startFetch).Seconds()) + m.lastSuccessfulFetch.With(ms.metricLabels).SetToCurrentTime() + m.secretState.With(ms.metricLabels).Set(stateSuccess) + } + + ms.secret = newSecret + ms.fetched = endFetch + ms.fetchCancel = nil + ms.refreshRequested = false + ms.mtx.Unlock() +} diff --git a/secrets/manager_test.go b/secrets/manager_test.go new file mode 100644 index 00000000..bfcb08e2 --- /dev/null +++ b/secrets/manager_test.go @@ -0,0 +1,242 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v2" +) + +func (mp *mockProvider) FetchSecret(ctx context.Context) (string, error) { + // Block if the test requires it, to simulate fetch latency. + if mp.blockChan != nil { + select { + case <-mp.blockChan: + case <-ctx.Done(): + return "", ctx.Err() + } + } + + // Release if the test requires it, to signal fetch has started. + if mp.releaseChan != nil { + close(mp.releaseChan) + } + + mp.mtx.RLock() + defer mp.mtx.RUnlock() + mp.fetchedLatest = true + return mp.Secret, mp.fetchErr +} + +func (mp *mockProvider) setSecret(s string) { + mp.mtx.Lock() + defer mp.mtx.Unlock() + mp.fetchedLatest = false + mp.Secret = s +} + +func (mp *mockProvider) setFetchError(err error) { + mp.mtx.Lock() + defer mp.mtx.Unlock() + mp.fetchedLatest = false + mp.fetchErr = err +} + +func (mp *mockProvider) hasFetchedLatest() bool { + mp.mtx.Lock() + defer mp.mtx.Unlock() + return mp.fetchedLatest +} + +type mockProvider struct { + Secret string `yaml:"secret"` + Id string `yaml:"id"` + mtx *sync.RWMutex + fetchErr error + fetchedLatest bool + blockChan chan struct{} + releaseChan chan struct{} +} + +func (mpc *mockProvider) NewProvider() (Provider, error) { + return mpc, nil +} + +func (mpc *mockProvider) UnmarshalYAML(unmarshal func(interface{}) error) error { + var s string + if err := unmarshal(&s); err == nil { + mpc.Secret = s + return nil + } + type plain mockProvider + return unmarshal((*plain)(mpc)) +} + +func (mpc *mockProvider) ID() string { + if len(mpc.Id) > 0 { + return mpc.Id + } + return mpc.Secret +} + +func (mpc *mockProvider) Clone() ProviderConfig { + mtx := mpc.mtx + if mtx == nil { + mtx = &sync.RWMutex{} + } + return &mockProvider{ + Secret: mpc.Secret, + fetchErr: mpc.fetchErr, + mtx: mtx, + fetchedLatest: mpc.fetchedLatest, + blockChan: mpc.blockChan, + releaseChan: mpc.releaseChan, + } +} + +// testConfig is a struct used for discovering SecretFields in tests. +type testConfig struct { + APIKeys []Field `yaml:"api_keys"` +} + +func parseConfig[T any](t *testing.T, content string) *T { + var cfg T + require.NoError(t, yaml.Unmarshal([]byte(content), &cfg)) + return &cfg +} + +func setupManagerTest[T any](t *testing.T, content string, mockPrototype *mockProvider) (*Manager, *T, func() *T) { + reg := prometheus.NewRegistry() + t.Cleanup(func() { + currentPolicy = func() bool { return true } + policyInitialized = atomic.Bool{} + }) + + providerReg := &ProviderRegistry{} + providerReg.Register("inline", &InlineProviderConfig{}) + providerReg.Register("file", &FileProviderConfig{}) + providerReg.Register("mock", mockPrototype) + + cfg := parseConfig[T](t, content) + + m, err := NewManager(promslog.NewNopLogger(), reg, providerReg, cfg) + require.NoError(t, err) + + m.Start(t.Context()) + return m, cfg, func() *T { + // Populate + cfg := parseConfig[T](t, content) + require.NoError(t, m.PopulateConfig(cfg)) + return cfg + } +} + +func fetchMockProvider(t *testing.T, field Field) *mockProvider { + config, ok := field.state.config.(*mockProvider) + require.Truef(t, ok, "fetching non-mock provider") + return config +} + +func TestNewManager(t *testing.T) { + content := ` +api_keys: + - mock: secret1 + - mock: secret2 + - "inline_secret" +` + + m, cfg, _ := setupManagerTest[testConfig](t, content, &mockProvider{}) + + require.Lenf(t, m.secrets, 3, "Manager should discover 3 secrets") + + assert.Equal(t, "mock>secret1", cfg.APIKeys[0].state.id()) + assert.Equal(t, "mock>secret2", cfg.APIKeys[1].state.id()) + assert.Equal(t, "inline>testConfig.APIKeys[2]", cfg.APIKeys[2].state.id()) + assert.NotNil(t, m.secrets[cfg.APIKeys[0].state.id()]) + assert.NotNil(t, m.secrets[cfg.APIKeys[1].state.id()]) + assert.NotNil(t, m.secrets[cfg.APIKeys[2].state.id()]) +} + +func TestManager_SecretLifecycle(t *testing.T) { + content := ` +api_keys: + - mock: initial_secret + refreshInterval: 50ms +` + _, cfg, populate := setupManagerTest[testConfig](t, content, &mockProvider{Id: "stable"}) + mock := fetchMockProvider(t, cfg.APIKeys[0]) + + // 1. Initial fetch + require.Eventuallyf(t, mock.hasFetchedLatest, time.Second, 10*time.Millisecond, "Initial fetch should occur") + cfg = populate() + assert.Equal(t, "initial_secret", cfg.APIKeys[0].Value()) + + // 2. Scheduled refresh + mock.setSecret("refreshed_secret") + require.Eventuallyf(t, mock.hasFetchedLatest, time.Second, 10*time.Millisecond, "Scheduled refresh should occur") + + cfg = populate() + assert.Equal(t, "refreshed_secret", cfg.APIKeys[0].Value()) + + // 3. Triggered refresh + mock.setSecret("triggered_secret") + cfg.APIKeys[0].TriggerRefresh() + require.Eventuallyf(t, mock.hasFetchedLatest, time.Second, 10*time.Millisecond, "Triggered refresh should occur") + cfg = populate() + assert.Equal(t, "triggered_secret", cfg.APIKeys[0].Value()) +} + +func TestManager_FetchErrorAndRecovery(t *testing.T) { + content := ` +api_keys: + - mock: "" +` + _, cfg, populate := setupManagerTest[testConfig](t, content, &mockProvider{ + fetchErr: errors.New("fetch failed"), + Id: "stable", + }) + mock := fetchMockProvider(t, cfg.APIKeys[0]) + + // Initial fetch fails. + assert.Truef(t, mock.hasFetchedLatest(), "A fetch should have been attempted") + assert.Emptyf(t, cfg.APIKeys[0].Value(), "Secret should be empty after failed fetch") + + // Recovery. + mock.setFetchError(nil) + mock.setSecret("recovered_secret") + require.Eventuallyf(t, func() bool { + return populate().APIKeys[0].Value() == "recovered_secret" + }, 2*time.Second, 50*time.Millisecond, "Manager should eventually get the correct secret") +} + +func TestManager_InlineSecret(t *testing.T) { + inlineSecret := "this-is-inline" + content := fmt.Sprintf(` +api_keys: + - "%s" +`, inlineSecret) + _, cfg, _ := setupManagerTest[testConfig](t, content, &mockProvider{}) + assert.Equal(t, inlineSecret, cfg.APIKeys[0].Value()) +} diff --git a/secrets/provider.go b/secrets/provider.go new file mode 100644 index 00000000..af5faa2b --- /dev/null +++ b/secrets/provider.go @@ -0,0 +1,54 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" +) + +// ProviderConfig is the configuration for a secret provider. +// +// To create a custom secret provider, you must implement this interface and +// register a new instance of your configuration struct with the +// ProviderRegistry. +// +// The NewProvider method should return a new Provider instance based on the +// configuration. +// +// The Clone method should return a deep copy of the configuration. +type ProviderConfig interface { + // NewProvider creates a new provider from the config. + NewProvider() (Provider, error) + // Clone clones the config. + Clone() ProviderConfig +} + +// ProviderConfigID is an interface for uniquely identifying a ProviderConfig +// instance. +// +// The ID method should return a string that is unique for each provider +// configuration. This is used by the Manager to identify and manage secrets. +type ProviderConfigID interface { + // ID returns a string that is equal iff the two provider configs output the same thing. + ID() string +} + +// Provider is the interface for a secret provider. +// +// A Provider is responsible for fetching a secret value from a source. +type Provider interface { + // FetchSecret retrieves the secret value. + // The context can be used to pass cancellation signals to the provider. + FetchSecret(ctx context.Context) (string, error) +} diff --git a/secrets/provider_example_test.go b/secrets/provider_example_test.go new file mode 100644 index 00000000..28d1773d --- /dev/null +++ b/secrets/provider_example_test.go @@ -0,0 +1,95 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets_test + +import ( + "context" + "fmt" + "os" + + "github.com/prometheus/client_golang/prometheus" + "go.yaml.in/yaml/v2" + + "github.com/prometheus/common/promslog" + "github.com/prometheus/common/secrets" +) + +// The following example demonstrates how to create and register a custom environment +// variable secret provider. + +// 1. Define a configuration struct. +type EnvProviderConfig struct { + Var string `yaml:"var"` +} + +// 1.1. Implement the secrets.ProviderConfig interface. +func (c *EnvProviderConfig) NewProvider() (secrets.Provider, error) { + return &envProvider{varName: c.Var}, nil +} + +func (c *EnvProviderConfig) Clone() secrets.ProviderConfig { + return &EnvProviderConfig{Var: c.Var} +} + +// 3. Define the provider. +type envProvider struct { + varName string +} + +// 3.1 Implement the provider interface. +func (p *envProvider) FetchSecret(_ context.Context) (string, error) { + return os.Getenv(p.varName), nil +} + +type MyConfig struct { + DBPassword secrets.Field `yaml:"db_password"` +} + +func ExampleProvider() { + // 4. Register the custom provider. + // This should be done in an init in a real application. + secrets.Providers.Register("env", &EnvProviderConfig{}) + + // Set the environment variable with the secret. + if err := os.Setenv("MY_SECRET_VAR", "secret_from_env"); err != nil { + panic(fmt.Errorf("Error setting environment variable: %w", err)) + } + defer os.Unsetenv("MY_SECRET_VAR") + + // User can then use your secret provider! + configData := []byte(` +db_password: + env: + var: MY_SECRET_VAR +`) + + var cfg MyConfig + if err := yaml.Unmarshal(configData, &cfg); err != nil { + panic(fmt.Errorf("Error unmarshaling config: %w", err)) + } + + // Create a secret manager to manage the secret. + manager, err := secrets.NewManager(promslog.NewNopLogger(), prometheus.NewRegistry(), secrets.Providers, &cfg) + if err != nil { + panic(fmt.Errorf("Error creating secret manager: %w", err)) + } + manager.Start(context.Background()) + defer manager.Stop() + + // Access the secret. + fmt.Printf("DB Password: %s\n", cfg.DBPassword.Value()) + + // Output: + // DB Password: secret_from_env +} diff --git a/secrets/registry.go b/secrets/registry.go new file mode 100644 index 00000000..2ae67720 --- /dev/null +++ b/secrets/registry.go @@ -0,0 +1,50 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import "fmt" + +// ProviderRegistry is a registry for secret provider configurations. +// +// It is used to register and retrieve secret provider configurations by name. +type ProviderRegistry struct { + providerConfigs map[string]ProviderConfig +} + +// Get retrieves a secret provider configuration by name. +// It returns a clone of the registered configuration. +func (r *ProviderRegistry) Get(name string) (ProviderConfig, error) { + if config, ok := r.providerConfigs[name]; ok { + return config.Clone(), nil + } + return nil, fmt.Errorf("unknown provider type: %q", name) +} + +// Register registers a new secret provider configuration. +// +// This method should be called in an init() function to register a custom +// secret provider. The name must be unique, or the method will panic. +func (r *ProviderRegistry) Register(name string, config ProviderConfig) { + if _, ok := r.providerConfigs[name]; ok { + panic(fmt.Sprintf("attempt to register duplicate type: %q", name)) + } + if r.providerConfigs == nil { + r.providerConfigs = make(map[string]ProviderConfig) + } + r.providerConfigs[name] = config +} + +// Providers is the global provider registry. +// Custom secret providers should be registered with this registry. +var Providers = &ProviderRegistry{} diff --git a/secrets/registry_test.go b/secrets/registry_test.go new file mode 100644 index 00000000..c50f4678 --- /dev/null +++ b/secrets/registry_test.go @@ -0,0 +1,78 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testProvider struct{} + +func (*testProvider) FetchSecret(_ context.Context) (string, error) { + return "test_secret", nil +} + +type testProviderConfig struct{} + +func (*testProviderConfig) NewProvider() (Provider, error) { + return &testProvider{}, nil +} + +func (*testProviderConfig) Clone() ProviderConfig { + return &testProviderConfig{} +} + +func TestProviderRegistry(t *testing.T) { + t.Run("GetInitialProviders", func(t *testing.T) { + // Test that providers from init() are registered in the global registry. + p, err := Providers.Get("inline") + require.NoError(t, err) + assert.IsType(t, &InlineProviderConfig{}, p) + + p, err = Providers.Get("file") + require.NoError(t, err) + assert.IsType(t, &FileProviderConfig{}, p) + }) + + t.Run("GetUnknownProvider", func(t *testing.T) { + _, err := Providers.Get("unknown-provider") + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown provider type: "unknown-provider"`) + }) + + t.Run("RegisterAndGet", func(t *testing.T) { + reg := &ProviderRegistry{} + config := &testProviderConfig{} + + reg.Register("test", config) + p, err := reg.Get("test") + require.NoError(t, err) + assert.IsType(t, &testProviderConfig{}, p) + }) + + t.Run("RegisterDuplicate", func(t *testing.T) { + reg := &ProviderRegistry{} + config1 := &testProviderConfig{} + config2 := &testProviderConfig{} + + reg.Register("duplicate", config1) + assert.PanicsWithValue(t, `attempt to register duplicate type: "duplicate"`, func() { + reg.Register("duplicate", config2) + }) + }) +} diff --git a/secrets/resolve.go b/secrets/resolve.go new file mode 100644 index 00000000..e3bdd1b3 --- /dev/null +++ b/secrets/resolve.go @@ -0,0 +1,138 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "fmt" + "reflect" + "sort" +) + +const ( + // maxRecursionDepth is the maximum depth for path traversals + // in the config. + maxRecursionDepth = 50 +) + +type fieldResults[T comparable] struct { + paths map[T]string + // ordered by increasing depth (root -> ... -> leafs) + ordered []T +} + +type fieldNode struct { + path string + val reflect.Value + depth int +} + +func (w *fieldNode) child(val reflect.Value, suffix string) fieldNode { + return fieldNode{ + path: w.path + suffix, + val: val, + depth: w.depth + 1, + } +} + +func getTypeName(v interface{}) string { + t := reflect.TypeOf(v) + + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t.Kind() == reflect.Struct { + return t.Name() + } + return t.Kind().String() +} + +func findFields[T comparable](v interface{}) (fieldResults[T], error) { + results := fieldResults[T]{ + paths: make(map[T]string), + } + + if v == nil || !reflect.ValueOf(v).IsValid() { + return results, nil + } + if reflect.ValueOf(v).Kind() == reflect.Struct { + return fieldResults[T]{}, fmt.Errorf("expected root to be pointer type, got struct %s instead", getTypeName(v)) + } + visited := make(map[uintptr]bool) + queue := []fieldNode{{path: getTypeName(v), val: reflect.ValueOf(v)}} + + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + + if node.depth > maxRecursionDepth { + return fieldResults[T]{}, fmt.Errorf("path traversal exceeded maximum depth (current depth: %d):\n%v", node.depth, node.path) + } + + queue = process(node, visited, queue) + if node.val.CanInterface() { + val := node.val + if reflect.TypeOf((*T)(nil)).Elem().Kind() != reflect.Interface { + if !val.CanAddr() { + continue + } + val = val.Addr() + } + field, ok := val.Interface().(T) + if !ok { + continue + } + results.paths[field] = node.path + results.ordered = append(results.ordered, field) + } + } + return results, nil +} + +func process(node fieldNode, visited map[uintptr]bool, queue []fieldNode) []fieldNode { + val := node.val + if !val.IsValid() { + return queue + } + switch val.Kind() { + case reflect.Ptr: + if val.IsNil() || visited[val.Pointer()] { + return queue + } + visited[val.Pointer()] = true + return append(queue, node.child(val.Elem(), "")) + case reflect.Interface: + return append(queue, node.child(val.Elem(), "")) + case reflect.Struct: + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + if field.CanInterface() { + queue = append(queue, node.child(field, "."+val.Type().Field(i).Name)) + } + } + case reflect.Slice, reflect.Array: + for i := 0; i < val.Len(); i++ { + queue = append(queue, node.child(val.Index(i), fmt.Sprintf("[%d]", i))) + } + case reflect.Map: + keys := val.MapKeys() + sort.Slice(keys, func(i, j int) bool { + return fmt.Sprintf("%v", keys[i].Interface()) < fmt.Sprintf("%v", keys[j].Interface()) + }) + for i, key := range keys { + queue = append(queue, node.child(key, fmt.Sprintf(".Keys()[%d]", i))) + queue = append(queue, node.child(val.MapIndex(key), fmt.Sprintf("[%v]", key.Interface()))) + } + } + return queue +} diff --git a/secrets/resolve_test.go b/secrets/resolve_test.go new file mode 100644 index 00000000..2dd4a29e --- /dev/null +++ b/secrets/resolve_test.go @@ -0,0 +1,415 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "fmt" + "reflect" + "strings" + "testing" +) + +type TestCase struct { + name string + input interface{} + want map[string]string + errContains string +} + +func newSF(secret string) Field { + return Field{ + state: &fieldState{ + providerName: "inline", + config: &InlineProviderConfig{ + secret: secret, + }, + }, + } +} + +func newSFRef(secret string) *Field { + val := newSF(secret) + return &val +} + +func normalizeSecretPaths(sp fieldResults[*Field]) map[string]string { + normalized := make(map[string]string) + for ptr, path := range sp.paths { + normalized[path] = ptr.state.config.(*InlineProviderConfig).secret + } + return normalized +} + +type SimpleStruct struct { + Secret Field + Day string +} + +type PrtStruct struct { + Secret *Field +} + +type ManyStruct struct { + Birthday Field + MothersMaidenName **Field + FavoriteColors []Field + BookReviews map[string]*Field + FavoriteMaterial Field +} + +type NestedStruct struct { + Nested SimpleStruct + TopSecret Field +} + +type NestedInterfaceStruct struct { + NestedInterface interface{} + TopSecretI Field +} + +type PtrNestedStruct struct { + Indirect *NestedStruct + Number int +} + +type PrivateField struct { + Exported Field + secret Field +} +type PrivateNestedField struct { + Nested PrivateField +} + +type DeeplyNestedStruct struct { + S Field + D *DeeplyNestedStruct +} + +func TestGetSecretFields(t *testing.T) { + pointer := newSF("pointer") + + tests := []TestCase{ + { + name: "Direct SecretField", + input: newSF("direct"), + errContains: "expected root to be pointer", + }, + { + name: "Plain SecretField", + input: &pointer, + want: map[string]string{ + "Field": "pointer", + }, + }, + { + name: "Pointer to SecretField", + input: &PrtStruct{Secret: &pointer}, + want: map[string]string{ + "PrtStruct.Secret": "pointer", + }, + }, + { + name: "Nil pointer to SecretField", + input: &PrtStruct{Secret: nil}, + want: map[string]string{}, + }, + { + name: "Simple struct with one SecretField", + input: &SimpleStruct{ + Secret: newSF("secret"), + Day: "Monday", + }, + want: map[string]string{ + "SimpleStruct.Secret": "secret", + }, + }, + { + name: "Struct with multiple SecretFields and nested pointers", + input: &ManyStruct{ + Birthday: newSF("happy_birthday"), + MothersMaidenName: func() **Field { + s := newSF("maiden_name") + p := &s + return &p + }(), + FavoriteColors: []Field{ + newSF("red"), + newSF("blue"), + newSF("green"), + }, + BookReviews: map[string]*Field{ + "The Hitchhiker's Guide to the Galaxy": newSFRef("hitchhiker_secret"), + "The Great Gatsby": newSFRef("gatsby_secret"), + }, + FavoriteMaterial: newSF("oak"), + }, + want: map[string]string{ + "ManyStruct.Birthday": "happy_birthday", + "ManyStruct.MothersMaidenName": "maiden_name", + "ManyStruct.FavoriteColors[0]": "red", + "ManyStruct.FavoriteColors[1]": "blue", + "ManyStruct.FavoriteColors[2]": "green", + "ManyStruct.BookReviews[The Great Gatsby]": "gatsby_secret", + "ManyStruct.BookReviews[The Hitchhiker's Guide to the Galaxy]": "hitchhiker_secret", + "ManyStruct.FavoriteMaterial": "oak", + }, + }, + { + name: "Nested struct with SecretFields at different levels", + input: &NestedStruct{ + Nested: SimpleStruct{ + Secret: newSF("inner_secret"), + Day: "Tuesday", + }, + TopSecret: newSF("outer_secret"), + }, + want: map[string]string{ + "NestedStruct.Nested.Secret": "inner_secret", + "NestedStruct.TopSecret": "outer_secret", + }, + }, + { + name: "Struct with nil pointer to nested struct", + input: &PtrNestedStruct{ + Indirect: nil, + Number: 10, + }, + want: map[string]string{}, + }, + { + name: "Struct with populated pointer to nested struct", + input: &PtrNestedStruct{ + Indirect: &NestedStruct{ + Nested: SimpleStruct{ + Secret: newSF("pointed_inner_secret"), + Day: "Wednesday", + }, + TopSecret: newSF("pointed_outer_secret"), + }, + Number: 20, + }, + want: map[string]string{ + "PtrNestedStruct.Indirect.Nested.Secret": "pointed_inner_secret", + "PtrNestedStruct.Indirect.TopSecret": "pointed_outer_secret", + }, + }, + { + name: "Struct with private secret field", + input: &PrivateField{ + Exported: newSF("exported_secret"), + secret: newSF("unexported_secret"), + }, + want: map[string]string{ + "PrivateField.Exported": "exported_secret", + }, + }, + { + name: "Nested struct with private secret field", + input: &PrivateNestedField{ + Nested: PrivateField{ + Exported: newSF("exported_secret"), + secret: newSF("unexported_secret"), + }, + }, + want: map[string]string{ + "PrivateNestedField.Nested.Exported": "exported_secret", + }, + }, + { + name: "Nil input", + input: nil, + want: map[string]string{}, + }, + { + name: "Pointer to nil input", + input: func() *int { + var x *int + return x + }(), + want: map[string]string{}, + }, + { + name: "Empty struct", + input: &struct{}{}, + want: map[string]string{}, + }, + { + name: "Struct with no SecretFields", + input: &struct { + Name string + Age int + }{ + Name: "John Doe", + Age: 30, + }, + want: map[string]string{}, + }, + { + name: "Deeply nested struct (should handle depth)", + input: &DeeplyNestedStruct{ + S: newSF("level_1"), + D: &DeeplyNestedStruct{ + S: newSF("level_2"), + D: &DeeplyNestedStruct{ + S: newSF("level_3"), + D: nil, + }, + }, + }, + want: map[string]string{ + "DeeplyNestedStruct.S": "level_1", + "DeeplyNestedStruct.D.S": "level_2", + "DeeplyNestedStruct.D.D.S": "level_3", + }, + }, + { + name: "Interface holding a SimpleStruct", + input: &NestedInterfaceStruct{ + NestedInterface: &SimpleStruct{ + Secret: newSF("interface_secret"), + Day: "Friday", + }, + TopSecretI: newSF("interface_top_secret"), + }, + want: map[string]string{ + "NestedInterfaceStruct.NestedInterface.Secret": "interface_secret", + "NestedInterfaceStruct.TopSecretI": "interface_top_secret", + }, + }, + { + name: "Interface holding a pointer to SimpleStruct", + input: &NestedInterfaceStruct{ + NestedInterface: &SimpleStruct{ + Secret: newSF("interface_ptr_secret"), + Day: "Saturday", + }, + TopSecretI: newSF("interface_ptr_top_secret"), + }, + want: map[string]string{ + "NestedInterfaceStruct.NestedInterface.Secret": "interface_ptr_secret", + "NestedInterfaceStruct.TopSecretI": "interface_ptr_top_secret", + }, + }, + { + name: "Interface holding a primitive type (no secrets)", + input: &NestedInterfaceStruct{ + NestedInterface: "hello world", + TopSecretI: newSF("primitive_interface_top_secret"), + }, + want: map[string]string{ + "NestedInterfaceStruct.TopSecretI": "primitive_interface_top_secret", + }, + }, + { + name: "Slice of SecretFields", + input: &[]Field{ + newSF("slice_secret_1"), + newSF("slice_secret_2"), + }, + want: map[string]string{ + "slice[0]": "slice_secret_1", + "slice[1]": "slice_secret_2", + }, + }, + { + name: "Map with SecretField values", + input: &map[string]Field{ + "key1": newSF("map_secret_1"), + "key2": newSF("map_secret_2"), + }, + want: map[string]string{}, + }, + { + name: "Map with SecretField key references", + input: &map[*Field]string{ + newSFRef("map_secret_1"): "val1", + }, + want: map[string]string{ + "map.Keys()[0]": "map_secret_1", + }, + }, + { + name: "Map with SecretField values references", + input: &map[string]*Field{ + "key1": newSFRef("map_secret_1"), + "key2": newSFRef("map_secret_2"), + }, + want: map[string]string{ + "map[key1]": "map_secret_1", + "map[key2]": "map_secret_2", + }, + }, + { + name: "Empty slice", + input: &[]SimpleStruct{}, + want: map[string]string{}, + }, + { + name: "Empty map", + input: &map[string]SimpleStruct{}, + want: map[string]string{}, + }, + { + name: "Deeply nested struct exceeding max depth (should return error)", + input: func() *DeeplyNestedStruct { + head := &DeeplyNestedStruct{S: newSF("head_secret")} + current := head + for i := 0; i < 51; i++ { // Create a chain longer than 50 + current.D = &DeeplyNestedStruct{S: newSF(fmt.Sprintf("level_%d", i))} + current = current.D + } + return head + }(), + errContains: "path traversal exceeded maximum depth", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotPaths, gotErr := findFields[*Field](tc.input) + + // Check for expected error. + if tc.errContains != "" { + if gotErr == nil { + t.Fatalf("Expected error containing '%s', but got no error", tc.errContains) + } + if !strings.Contains(gotErr.Error(), tc.errContains) { + t.Errorf("Expected error containing '%s', but got: %v", tc.errContains, gotErr) + } + return + } + + if gotErr != nil { + t.Fatalf("Did not expect an error, but got: %v", gotErr) + } + + normalizedGotPaths := normalizeSecretPaths(gotPaths) + + if !reflect.DeepEqual(normalizedGotPaths, tc.want) { + t.Errorf("GetSecretFields() got = %v, want %v", normalizedGotPaths, tc.want) + for k, v := range tc.want { + if actualVal, ok := normalizedGotPaths[k]; !ok { + t.Errorf("Missing %v = %q", k, v) + } else if actualVal != v { + t.Errorf("Mismatch %v = %q (want %q)", k, actualVal, v) + } + } + for k, v := range normalizedGotPaths { + if _, ok := tc.want[k]; !ok { + t.Errorf("Unexpected %v = %q", k, v) + } + } + } + }) + } +} diff --git a/secrets/util.go b/secrets/util.go new file mode 100644 index 00000000..3f680ecb --- /dev/null +++ b/secrets/util.go @@ -0,0 +1,24 @@ +// Copyright 2025 The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package secrets + +func count[T comparable](values ...T) int { + count := 0 + var zero T + for _, value := range values { + if value != zero { + count++ + } + } + return count +}