-
-
Notifications
You must be signed in to change notification settings - Fork 112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Google secret manager store implementation #1034
base: main
Are you sure you want to change the base?
Changes from 16 commits
4cc1c19
dfab44d
6e490f9
7088c15
21051e7
5c5213c
a594edb
65e9ab8
afebd17
e605636
2c61183
dd49233
b5a966c
0dc538c
daa8b1d
c355610
33ae030
297c229
c545b86
c21ac6c
4a13579
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,248 @@ | ||||||
package store | ||||||
|
||||||
import ( | ||||||
"context" | ||||||
"fmt" | ||||||
"strings" | ||||||
"time" | ||||||
|
||||||
secretmanager "cloud.google.com/go/secretmanager/apiv1" | ||||||
secretmanagerpb "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" | ||||||
"github.com/charmbracelet/log" | ||||||
"github.com/googleapis/gax-go/v2" | ||||||
"google.golang.org/api/option" | ||||||
) | ||||||
|
||||||
const ( | ||||||
gsmOperationTimeout = 30 * time.Second | ||||||
) | ||||||
|
||||||
// GSMClient is the interface that wraps the Google Secret Manager client methods we use | ||||||
type GSMClient interface { | ||||||
CreateSecret(ctx context.Context, req *secretmanagerpb.CreateSecretRequest, opts ...gax.CallOption) (*secretmanagerpb.Secret, error) | ||||||
AddSecretVersion(ctx context.Context, req *secretmanagerpb.AddSecretVersionRequest, opts ...gax.CallOption) (*secretmanagerpb.SecretVersion, error) | ||||||
AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...gax.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) | ||||||
Close() error | ||||||
} | ||||||
|
||||||
// GSMStore is an implementation of the Store interface for Google Secret Manager. | ||||||
type GSMStore struct { | ||||||
client GSMClient | ||||||
projectID string | ||||||
prefix string | ||||||
stackDelimiter *string | ||||||
} | ||||||
|
||||||
type GSMStoreOptions struct { | ||||||
Prefix *string `mapstructure:"prefix"` | ||||||
ProjectID string `mapstructure:"project_id"` | ||||||
StackDelimiter *string `mapstructure:"stack_delimiter"` | ||||||
Credentials *string `mapstructure:"credentials"` // Optional JSON credentials | ||||||
} | ||||||
|
||||||
// Ensure GSMStore implements the store.Store interface. | ||||||
var _ Store = (*GSMStore)(nil) | ||||||
|
||||||
// NewGSMStore initializes a new Google Secret Manager Store. | ||||||
func NewGSMStore(options GSMStoreOptions) (Store, error) { | ||||||
ctx := context.Background() | ||||||
|
||||||
if options.ProjectID == "" { | ||||||
return nil, fmt.Errorf("project_id is required in Google Secret Manager store configuration") | ||||||
} | ||||||
|
||||||
var clientOpts []option.ClientOption | ||||||
if options.Credentials != nil { | ||||||
clientOpts = append(clientOpts, option.WithCredentialsJSON([]byte(*options.Credentials))) | ||||||
} | ||||||
|
||||||
client, err := secretmanager.NewClient(ctx, clientOpts...) | ||||||
if err != nil { | ||||||
// Close the client to prevent resource leaks | ||||||
if client != nil { | ||||||
client.Close() | ||||||
} | ||||||
return nil, fmt.Errorf("failed to create Secret Manager client: %w", err) | ||||||
} | ||||||
|
||||||
store := &GSMStore{ | ||||||
client: client, | ||||||
projectID: options.ProjectID, | ||||||
} | ||||||
|
||||||
if options.Prefix != nil { | ||||||
store.prefix = *options.Prefix | ||||||
} | ||||||
|
||||||
if options.StackDelimiter != nil { | ||||||
store.stackDelimiter = options.StackDelimiter | ||||||
} else { | ||||||
defaultDelimiter := "-" | ||||||
store.stackDelimiter = &defaultDelimiter | ||||||
} | ||||||
|
||||||
return store, nil | ||||||
} | ||||||
|
||||||
func (s *GSMStore) getKey(stack string, component string, key string) (string, error) { | ||||||
if s.stackDelimiter == nil { | ||||||
return "", fmt.Errorf("stack delimiter is not set") | ||||||
} | ||||||
|
||||||
// Get the base key using the common getKey function | ||||||
baseKey, err := getKey(s.prefix, *s.stackDelimiter, stack, component, key, "_") | ||||||
if err != nil { | ||||||
return "", err | ||||||
} | ||||||
|
||||||
// Replace any remaining slashes with underscores as Secret Manager doesn't allow slashes | ||||||
baseKey = strings.ReplaceAll(baseKey, "/", "_") | ||||||
// Remove any double underscores that might have been created | ||||||
baseKey = strings.ReplaceAll(baseKey, "__", "_") | ||||||
// Trim any leading or trailing underscores | ||||||
baseKey = strings.Trim(baseKey, "_") | ||||||
|
||||||
return baseKey, nil | ||||||
} | ||||||
|
||||||
// Set stores a key-value pair in Google Secret Manager. | ||||||
func (s *GSMStore) Set(stack string, component string, key string, value interface{}) error { | ||||||
ctx, cancel := context.WithTimeout(context.Background(), gsmOperationTimeout) | ||||||
defer cancel() | ||||||
|
||||||
if stack == "" { | ||||||
return fmt.Errorf("stack cannot be empty") | ||||||
} | ||||||
if component == "" { | ||||||
return fmt.Errorf("component cannot be empty") | ||||||
} | ||||||
if key == "" { | ||||||
return fmt.Errorf("key cannot be empty") | ||||||
} | ||||||
|
||||||
// Convert value to string | ||||||
strValue, ok := value.(string) | ||||||
if !ok { | ||||||
return fmt.Errorf("value must be a string") | ||||||
} | ||||||
|
||||||
// Get the secret ID using getKey | ||||||
secretID, err := s.getKey(stack, component, key) | ||||||
if err != nil { | ||||||
return fmt.Errorf("failed to get key: %w", err) | ||||||
} | ||||||
|
||||||
// Create the secret if it doesn't exist | ||||||
parent := fmt.Sprintf("projects/%s", s.projectID) | ||||||
createSecretReq := &secretmanagerpb.CreateSecretRequest{ | ||||||
Parent: parent, | ||||||
SecretId: secretID, | ||||||
Secret: &secretmanagerpb.Secret{ | ||||||
Replication: &secretmanagerpb.Replication{ | ||||||
Replication: &secretmanagerpb.Replication_Automatic_{ | ||||||
Automatic: &secretmanagerpb.Replication_Automatic{}, | ||||||
}, | ||||||
}, | ||||||
}, | ||||||
} | ||||||
|
||||||
log.Debug("creating/updating Google Secret Manager secret", | ||||||
"project", s.projectID, | ||||||
"secret_id", secretID, | ||||||
"stack", stack, | ||||||
"component", component, | ||||||
"key", key) | ||||||
|
||||||
secret, err := s.client.CreateSecret(ctx, createSecretReq) | ||||||
if err != nil { | ||||||
// Ignore error if secret already exists | ||||||
if !strings.Contains(err.Error(), "already exists") { | ||||||
log.Debug("failed to create secret", | ||||||
"project", s.projectID, | ||||||
"secret_id", secretID, | ||||||
"error", err) | ||||||
return fmt.Errorf("failed to create secret: %w", err) | ||||||
} | ||||||
log.Debug("secret already exists", | ||||||
"project", s.projectID, | ||||||
"secret_id", secretID) | ||||||
// If the secret already exists, construct the name manually | ||||||
secret = &secretmanagerpb.Secret{ | ||||||
Name: fmt.Sprintf("projects/%s/secrets/%s", s.projectID, secretID), | ||||||
} | ||||||
} else { | ||||||
log.Debug("successfully created secret", | ||||||
"name", secret.GetName()) | ||||||
} | ||||||
|
||||||
// Add new version with the secret value | ||||||
addVersionReq := &secretmanagerpb.AddSecretVersionRequest{ | ||||||
Parent: secret.GetName(), | ||||||
Payload: &secretmanagerpb.SecretPayload{ | ||||||
Data: []byte(strValue), | ||||||
}, | ||||||
} | ||||||
|
||||||
log.Debug("adding new version to secret", | ||||||
"name", secret.GetName()) | ||||||
|
||||||
_, err = s.client.AddSecretVersion(ctx, addVersionReq) | ||||||
if err != nil { | ||||||
log.Debug("failed to add version to secret", | ||||||
"name", secret.GetName(), | ||||||
"error", err) | ||||||
return fmt.Errorf("failed to add secret version: %w", err) | ||||||
} | ||||||
|
||||||
log.Debug("successfully added new version to secret", | ||||||
"name", secret.GetName()) | ||||||
return nil | ||||||
} | ||||||
|
||||||
// Get retrieves a value by key from Google Secret Manager. | ||||||
func (s *GSMStore) Get(stack string, component string, key string) (interface{}, error) { | ||||||
ctx, cancel := context.WithTimeout(context.Background(), gsmOperationTimeout) | ||||||
defer cancel() | ||||||
|
||||||
if stack == "" { | ||||||
return nil, fmt.Errorf("stack cannot be empty") | ||||||
} | ||||||
if component == "" { | ||||||
return nil, fmt.Errorf("component cannot be empty") | ||||||
} | ||||||
if key == "" { | ||||||
return nil, fmt.Errorf("key cannot be empty") | ||||||
} | ||||||
|
||||||
// Get the secret ID using getKey | ||||||
secretID, err := s.getKey(stack, component, key) | ||||||
if err != nil { | ||||||
return nil, fmt.Errorf("failed to get key: %w", err) | ||||||
} | ||||||
|
||||||
// Build the resource name for the latest version | ||||||
name := fmt.Sprintf("projects/%s/secrets/%s/versions/latest", s.projectID, secretID) | ||||||
|
||||||
log.Debug("accessing Google Secret Manager secret", | ||||||
"name", name, | ||||||
"project", s.projectID, | ||||||
"secret_id", secretID, | ||||||
"stack", stack, | ||||||
"component", component, | ||||||
"key", key) | ||||||
|
||||||
// Access the secret version | ||||||
result, err := s.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{ | ||||||
Name: name, | ||||||
}) | ||||||
if err != nil { | ||||||
log.Debug("failed to access secret", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
"name", name, | ||||||
"error", err) | ||||||
return nil, fmt.Errorf("failed to access secret version: %w", err) | ||||||
} | ||||||
|
||||||
log.Debug("successfully accessed secret", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
"name", name) | ||||||
return string(result.Payload.Data), nil | ||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.