Skip to content

Commit 0407b05

Browse files
authored
Feature/cloudsql secret manager (#1273)
* initial commit * initial commit * added empty password check in mysql * changed naming * added missed files, fixed merge issues * design changes, added tests * Added utils * tests , refactoring * naming change for password secret id
1 parent a3bdbb6 commit 0407b05

10 files changed

Lines changed: 694 additions & 1184 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package secretmanagerclient
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
8+
secretmanager "cloud.google.com/go/secretmanager/apiv1"
9+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
10+
googleapis "github.com/googleapis/gax-go/v2"
11+
"google.golang.org/api/option"
12+
)
13+
14+
type SecretManagerClient interface {
15+
AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...googleapis.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error)
16+
Close() error
17+
}
18+
19+
// ClientCreator is a function type for creating a Secret Manager client.
20+
type ClientCreator func(ctx context.Context, opts ...option.ClientOption) (*secretmanager.Client, error)
21+
22+
// SecretManagerClientFactory manages the creation of Secret Manager clients.
23+
type SecretManagerClientFactory struct {
24+
client *secretmanager.Client
25+
creator ClientCreator
26+
once sync.Once
27+
}
28+
29+
var defaultFactory = &SecretManagerClientFactory{
30+
creator: secretmanager.NewClient,
31+
}
32+
33+
34+
func (f *SecretManagerClientFactory) GetOrCreateClient(ctx context.Context) (*secretmanager.Client, error) {
35+
var err error
36+
f.once.Do(func() {
37+
f.client, err = f.creator(ctx)
38+
})
39+
if err != nil {
40+
return nil, fmt.Errorf("failed to create secret manager client: %v", err)
41+
}
42+
return f.client, nil
43+
}
44+
45+
46+
var NewSecretManagerClient = func(ctx context.Context) (SecretManagerClient, error) {
47+
return defaultFactory.GetOrCreateClient(ctx)
48+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package secretmanagerclient
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
secretmanager "cloud.google.com/go/secretmanager/apiv1"
9+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
10+
googleapis "github.com/googleapis/gax-go/v2"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/mock"
13+
"google.golang.org/api/option"
14+
)
15+
16+
// MockSecretManagerClient is a mock of SecretManagerClient interface
17+
type MockSecretManagerClient struct {
18+
mock.Mock
19+
}
20+
21+
func (m *MockSecretManagerClient) AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...googleapis.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) {
22+
args := m.Called(ctx, req, opts)
23+
return args.Get(0).(*secretmanagerpb.AccessSecretVersionResponse), args.Error(1)
24+
}
25+
26+
func (m *MockSecretManagerClient) Close() error {
27+
args := m.Called()
28+
return args.Error(0)
29+
}
30+
31+
func TestGetOrCreateClient(t *testing.T) {
32+
callCount := 0
33+
creator := func(ctx context.Context, opts ...option.ClientOption) (*secretmanager.Client, error) {
34+
callCount++
35+
return &secretmanager.Client{}, nil
36+
}
37+
38+
factory := &SecretManagerClientFactory{
39+
creator: creator,
40+
}
41+
42+
ctx := context.Background()
43+
client1, err := factory.GetOrCreateClient(ctx)
44+
assert.NoError(t, err)
45+
assert.NotNil(t, client1)
46+
47+
// Call again, should return same instance and not increment callCount
48+
client2, err := factory.GetOrCreateClient(ctx)
49+
assert.NoError(t, err)
50+
assert.Equal(t, client1, client2)
51+
assert.Equal(t, 1, callCount)
52+
}
53+
54+
func TestGetOrCreateClient_Error(t *testing.T) {
55+
56+
expectedErr := fmt.Errorf("client creation failed")
57+
creator := func(ctx context.Context, opts ...option.ClientOption) (*secretmanager.Client, error) {
58+
return nil, expectedErr
59+
}
60+
61+
factory := &SecretManagerClientFactory{
62+
creator: creator,
63+
}
64+
65+
ctx := context.Background()
66+
client, err := factory.GetOrCreateClient(ctx)
67+
assert.Error(t, err)
68+
assert.Nil(t, client)
69+
assert.Contains(t, err.Error(), expectedErr.Error())
70+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package secretmanageraccessor
15+
16+
import (
17+
"context"
18+
"fmt"
19+
20+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
21+
secretmanagerclient "github.com/GoogleCloudPlatform/spanner-migration-tool/accessors/clients/secretmanager"
22+
)
23+
24+
type SecretManagerAccessor interface {
25+
GetSecret(ctx context.Context, secretId string) (string, error)
26+
}
27+
28+
type SecretManagerAccessorImpl struct {
29+
Client secretmanagerclient.SecretManagerClient
30+
}
31+
32+
func (sma *SecretManagerAccessorImpl) GetSecret(ctx context.Context, secretId string) (string, error) {
33+
req := &secretmanagerpb.AccessSecretVersionRequest{
34+
Name: secretId,
35+
}
36+
37+
result, err := sma.Client.AccessSecretVersion(ctx, req)
38+
if err != nil {
39+
return "", fmt.Errorf("failed to access secret version: %v", err)
40+
}
41+
42+
return string(result.Payload.Data), nil
43+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package secretmanageraccessor
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
9+
googleapis "github.com/googleapis/gax-go/v2"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/mock"
12+
)
13+
14+
// MockSecretManagerClient is a mock of SecretManagerClient interface
15+
type MockSecretManagerClient struct {
16+
mock.Mock
17+
}
18+
19+
func (m *MockSecretManagerClient) AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...googleapis.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) {
20+
args := m.Called(ctx, req, opts)
21+
return args.Get(0).(*secretmanagerpb.AccessSecretVersionResponse), args.Error(1)
22+
}
23+
24+
func (m *MockSecretManagerClient) Close() error {
25+
args := m.Called()
26+
return args.Error(0)
27+
}
28+
29+
func TestGetSecret(t *testing.T) {
30+
mockClient := new(MockSecretManagerClient)
31+
accessor := &SecretManagerAccessorImpl{Client: mockClient}
32+
ctx := context.Background()
33+
secretId := "projects/my-project/secrets/my-secret/versions/latest"
34+
expectedPwd := "my-password"
35+
36+
mockClient.On("AccessSecretVersion", ctx, mock.MatchedBy(func(req *secretmanagerpb.AccessSecretVersionRequest) bool {
37+
return req.Name == secretId
38+
}), mock.Anything).Return(&secretmanagerpb.AccessSecretVersionResponse{
39+
Payload: &secretmanagerpb.SecretPayload{
40+
Data: []byte(expectedPwd),
41+
},
42+
}, nil)
43+
44+
pwd, err := accessor.GetSecret(ctx, secretId)
45+
assert.NoError(t, err)
46+
assert.Equal(t, expectedPwd, pwd)
47+
mockClient.AssertExpectations(t)
48+
}
49+
50+
func TestGetSecret_Error(t *testing.T) {
51+
mockClient := new(MockSecretManagerClient)
52+
accessor := &SecretManagerAccessorImpl{Client: mockClient}
53+
ctx := context.Background()
54+
secretId := "projects/my-project/secrets/my-secret/versions/latest"
55+
56+
mockClient.On("AccessSecretVersion", ctx, mock.MatchedBy(func(req *secretmanagerpb.AccessSecretVersionRequest) bool {
57+
return req.Name == secretId
58+
}), mock.Anything).Return(&secretmanagerpb.AccessSecretVersionResponse{}, fmt.Errorf("client error"))
59+
60+
_, err := accessor.GetSecret(ctx, secretId)
61+
assert.Error(t, err)
62+
assert.Contains(t, err.Error(), "failed to access secret version")
63+
mockClient.AssertExpectations(t)
64+
}

common/utils/secrets.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
secretmanagerclient "github.com/GoogleCloudPlatform/spanner-migration-tool/accessors/clients/secretmanager"
9+
secretmanageraccessor "github.com/GoogleCloudPlatform/spanner-migration-tool/accessors/secretmanager"
10+
)
11+
12+
// FetchPasswordFromSecretManager fetches the password from Secret Manager.
13+
// It returns the resolved secret ID (with version if added), the password, and any error.
14+
func FetchPasswordFromSecretManager(secretId string) (string, string, error) {
15+
secretId = strings.TrimSuffix(secretId, "/")
16+
if !strings.Contains(secretId, "/versions/") {
17+
secretId += "/versions/latest"
18+
}
19+
ctx := context.Background()
20+
smc, err := secretmanagerclient.NewSecretManagerClient(ctx)
21+
if err != nil {
22+
return secretId, "", fmt.Errorf("failed to create secret manager client: %v", err)
23+
}
24+
25+
sma := &secretmanageraccessor.SecretManagerAccessorImpl{Client: smc}
26+
pwd, err := sma.GetSecret(ctx, secretId)
27+
if err != nil {
28+
return secretId, "", fmt.Errorf("failed to fetch password from secret manager: %v", err)
29+
}
30+
return secretId, pwd, nil
31+
}

common/utils/secrets_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
9+
secretmanagerclient "github.com/GoogleCloudPlatform/spanner-migration-tool/accessors/clients/secretmanager"
10+
googleapis "github.com/googleapis/gax-go/v2"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/mock"
13+
)
14+
15+
type MockSecretManagerClient struct {
16+
mock.Mock
17+
}
18+
19+
func (m *MockSecretManagerClient) AccessSecretVersion(ctx context.Context, req *secretmanagerpb.AccessSecretVersionRequest, opts ...googleapis.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) {
20+
args := m.Called(ctx, req, opts)
21+
return args.Get(0).(*secretmanagerpb.AccessSecretVersionResponse), args.Error(1)
22+
}
23+
24+
func (m *MockSecretManagerClient) Close() error {
25+
args := m.Called()
26+
return args.Error(0)
27+
}
28+
29+
func TestFetchPasswordFromSecretManager(t *testing.T) {
30+
mockClient := new(MockSecretManagerClient)
31+
32+
// Override the client creation function
33+
oldNewClient := secretmanagerclient.NewSecretManagerClient
34+
secretmanagerclient.NewSecretManagerClient = func(ctx context.Context) (secretmanagerclient.SecretManagerClient, error) {
35+
return mockClient, nil
36+
}
37+
defer func() { secretmanagerclient.NewSecretManagerClient = oldNewClient }()
38+
39+
testCases := []struct {
40+
name string
41+
secretId string
42+
mockResponse *secretmanagerpb.AccessSecretVersionResponse
43+
mockError error
44+
expectedSecret string
45+
expectedPwd string
46+
expectError bool
47+
}{
48+
{
49+
name: "Success with version",
50+
secretId: "projects/my-project/secrets/my-secret/versions/1",
51+
mockResponse: &secretmanagerpb.AccessSecretVersionResponse{
52+
Payload: &secretmanagerpb.SecretPayload{
53+
Data: []byte("password123"),
54+
},
55+
},
56+
expectedSecret: "projects/my-project/secrets/my-secret/versions/1",
57+
expectedPwd: "password123",
58+
expectError: false,
59+
},
60+
{
61+
name: "Success without version (appends latest)",
62+
secretId: "projects/my-project/secrets/my-secret",
63+
mockResponse: &secretmanagerpb.AccessSecretVersionResponse{
64+
Payload: &secretmanagerpb.SecretPayload{
65+
Data: []byte("password456"),
66+
},
67+
},
68+
expectedSecret: "projects/my-project/secrets/my-secret/versions/latest",
69+
expectedPwd: "password456",
70+
expectError: false,
71+
},
72+
{
73+
name: "Client error",
74+
secretId: "projects/my-project/secrets/my-secret",
75+
mockError: fmt.Errorf("client error"),
76+
expectError: true,
77+
},
78+
{
79+
name: "Success with trailing slash (appends latest)",
80+
secretId: "projects/my-project/secrets/my-secret/",
81+
mockResponse: &secretmanagerpb.AccessSecretVersionResponse{
82+
Payload: &secretmanagerpb.SecretPayload{
83+
Data: []byte("password789"),
84+
},
85+
},
86+
expectedSecret: "projects/my-project/secrets/my-secret/versions/latest",
87+
expectedPwd: "password789",
88+
expectError: false,
89+
},
90+
}
91+
92+
for _, tc := range testCases {
93+
t.Run(tc.name, func(t *testing.T) {
94+
if !tc.expectError {
95+
mockClient.On("AccessSecretVersion", mock.Anything, mock.MatchedBy(func(req *secretmanagerpb.AccessSecretVersionRequest) bool {
96+
return req.Name == tc.expectedSecret
97+
}), mock.Anything).Return(tc.mockResponse, nil).Once()
98+
} else if tc.mockError != nil {
99+
mockClient.On("AccessSecretVersion", mock.Anything, mock.Anything, mock.Anything).Return(&secretmanagerpb.AccessSecretVersionResponse{}, tc.mockError).Once()
100+
}
101+
102+
secretId, pwd, err := FetchPasswordFromSecretManager(tc.secretId)
103+
104+
if tc.expectError {
105+
assert.Error(t, err)
106+
} else {
107+
assert.NoError(t, err)
108+
assert.Equal(t, tc.expectedSecret, secretId)
109+
assert.Equal(t, tc.expectedPwd, pwd)
110+
}
111+
})
112+
}
113+
}

0 commit comments

Comments
 (0)