-
Notifications
You must be signed in to change notification settings - Fork 1
Add a simple GCS storage impl #3
Changes from 1 commit
ba92a6c
8ad4b7b
c661d59
d35e88e
5a75d4a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
package gcs | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"io/ioutil" | ||
"os" | ||
|
||
gcStorage "cloud.google.com/go/storage" | ||
) | ||
|
||
type Client interface { | ||
Read(ctx context.Context) ([]byte, error) | ||
Write(ctx context.Context, p []byte) error | ||
} | ||
|
||
type Adapter struct { | ||
config Config | ||
client *gcStorage.Client | ||
} | ||
|
||
func (a Adapter) Read(ctx context.Context) ([]byte, error) { | ||
r, err := a.client.Bucket(a.config.Bucket).Object(a.config.Name).NewReader(ctx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
defer r.Close() | ||
|
||
body, err := ioutil.ReadAll(r) | ||
KengoTODA marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err != nil { | ||
return nil, fmt.Errorf("failed reading from gcs://%s/%s: %w", a.config.Bucket, a.config.Name, err) | ||
} | ||
return body, nil | ||
} | ||
|
||
func (a Adapter) Write(ctx context.Context, p []byte) error { | ||
w := a.client.Bucket(a.config.Bucket).Object(a.config.Name).NewWriter(ctx) | ||
_, err := w.Write(p) | ||
|
||
if err != nil { | ||
return fmt.Errorf("failed writing to gcs://%s/%s: %w", a.config.Bucket, a.config.Name, err) | ||
} | ||
return w.Close() | ||
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. I think we need to use 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. It is because:
For reference, a sample code in the official document handles error in the same manner: // Write some text to obj. This will either create the object or overwrite whatever is there already.
if _, err := fmt.Fprintf(w, "This object contains text.\n"); err != nil {
// TODO: Handle error.
}
// Close, just like writing a file.
if err := w.Close(); err != nil {
// TODO: Handle error.
}
// Read it back.
r, err := obj.NewReader(ctx)
if err != nil {
// TODO: Handle error.
}
defer r.Close() 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. Got it. Though it’s technically possible to return an error from a defer function by using a named return value in general, I don’t have much knowledge about the GCS library implementation. It’s better to follow an official example unless we hit an actual issue. |
||
} | ||
|
||
func NewClient(ctx context.Context, config Config) (Client, error) { | ||
if config.Endpoint == "" { | ||
// https://pkg.go.dev/cloud.google.com/go/storage#hdr-Creating_a_Client | ||
err := os.Setenv("STORAGE_EMULATOR_HOST", config.Endpoint) | ||
KengoTODA marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if err != nil { | ||
return nil, err | ||
} | ||
} | ||
|
||
c, err := gcStorage.NewClient(ctx) | ||
a := &Adapter{ | ||
config: config, | ||
client: c, | ||
} | ||
return a, err | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
package gcs | ||
|
||
import storage "github.com/minamijoyo/tfmigrate-storage" | ||
|
||
// Config is a config for Google Cloud Storage. | ||
// This is expected to have almost the same options as Terraform gcs backend. | ||
// https://www.terraform.io/language/settings/backends/gcs | ||
// However, it has many minor options and it's a pain to test all options from | ||
// first, so we added only options we need for now. | ||
type Config struct { | ||
// The name of the GCS bucket. | ||
Bucket string `hcl:"bucket"` | ||
// Path to the migration history file. | ||
Name string `hcl:"name"` | ||
|
||
Endpoint string `hcl:"endpoint,optional"` | ||
} | ||
|
||
// Config implements a storage.Config. | ||
var _ storage.Config = (*Config)(nil) | ||
|
||
// NewStorage returns a new instance of storage.Storage. | ||
func (c *Config) NewStorage() (storage.Storage, error) { | ||
return NewStorage(c, nil) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package gcs | ||
|
||
import "testing" | ||
|
||
func TestConfigNewStorage(t *testing.T) { | ||
cases := []struct { | ||
desc string | ||
config *Config | ||
ok bool | ||
}{ | ||
{ | ||
desc: "valid", | ||
config: &Config{ | ||
Bucket: "tfmigrate-test", | ||
Name: "tfmigrate/history.json", | ||
}, | ||
ok: true, | ||
}, | ||
} | ||
|
||
for _, tc := range cases { | ||
t.Run(tc.desc, func(t *testing.T) { | ||
got, err := tc.config.NewStorage() | ||
if tc.ok && err != nil { | ||
t.Fatalf("unexpected err: %s", err) | ||
} | ||
if !tc.ok && err == nil { | ||
t.Fatalf("expected to return an error, but no error, got: %#v", got) | ||
} | ||
if tc.ok { | ||
_ = got.(*Storage) | ||
} | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package gcs | ||
|
||
import ( | ||
"context" | ||
|
||
gcStorage "cloud.google.com/go/storage" | ||
storage "github.com/minamijoyo/tfmigrate-storage" | ||
) | ||
|
||
type Storage struct { | ||
// config is a storage config for GCS. | ||
config *Config | ||
// client is an instance of Client interface to call API. | ||
// It is intended to be replaced with a mock for testing. | ||
// https://pkg.go.dev/cloud.google.com/go/storage#Client | ||
client Client | ||
} | ||
|
||
var _ storage.Storage = (*Storage)(nil) | ||
|
||
// NewStorage returns a new instance of Storage. | ||
func NewStorage(config *Config, client Client) (*Storage, error) { | ||
s := &Storage{ | ||
config: config, | ||
client: client, | ||
} | ||
return s, nil | ||
} | ||
|
||
func (s *Storage) Write(ctx context.Context, b []byte) error { | ||
if s.client == nil { | ||
client, err := gcStorage.NewClient(ctx) | ||
if err != nil { | ||
return err | ||
} | ||
s.client = Adapter{ | ||
config: *s.config, | ||
client: client, | ||
} | ||
} | ||
|
||
return s.client.Write(ctx, b) | ||
} | ||
|
||
func (s *Storage) Read(ctx context.Context) ([]byte, error) { | ||
if s.client == nil { | ||
client, err := gcStorage.NewClient(ctx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
s.client = Adapter{ | ||
config: *s.config, | ||
client: client, | ||
} | ||
} | ||
KengoTODA marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
r, err := s.client.Read(ctx) | ||
if err == gcStorage.ErrObjectNotExist { | ||
return []byte{}, nil | ||
} else if err != nil { | ||
return nil, err | ||
} | ||
return r, nil | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
package gcs | ||
|
||
import ( | ||
"context" | ||
"testing" | ||
|
||
gcStorage "cloud.google.com/go/storage" | ||
) | ||
|
||
// mockClient is a mock implementation for testing. | ||
type mockClient struct { | ||
dataToRead []byte | ||
err error | ||
} | ||
|
||
func (c *mockClient) Read(ctx context.Context) ([]byte, error) { | ||
return c.dataToRead, c.err | ||
} | ||
|
||
func (c *mockClient) Write(ctx context.Context, b []byte) error { | ||
return c.err | ||
} | ||
|
||
func TestStorageWrite(t *testing.T) { | ||
cases := []struct { | ||
desc string | ||
config *Config | ||
client Client | ||
contents []byte | ||
ok bool | ||
}{ | ||
{ | ||
desc: "simple", | ||
config: &Config{ | ||
Bucket: "tfmigrate-test", | ||
Name: "tfmigrate/history.json", | ||
}, | ||
client: &mockClient{ | ||
err: nil, | ||
}, | ||
contents: []byte("foo"), | ||
ok: true, | ||
}, | ||
{ | ||
desc: "bucket does not exist", | ||
config: &Config{ | ||
Bucket: "not-exist-bucket", | ||
Name: "tfmigrate/history.json", | ||
}, | ||
client: &mockClient{ | ||
err: gcStorage.ErrBucketNotExist, | ||
}, | ||
contents: []byte("foo"), | ||
ok: false, | ||
}, | ||
} | ||
|
||
for _, tc := range cases { | ||
t.Run(tc.desc, func(t *testing.T) { | ||
s, err := NewStorage(tc.config, tc.client) | ||
if err != nil { | ||
t.Fatalf("failed to NewStorage: %s", err) | ||
} | ||
err = s.Write(context.Background(), tc.contents) | ||
if tc.ok && err != nil { | ||
t.Fatalf("unexpected err: %s", err) | ||
} | ||
if !tc.ok && err == nil { | ||
t.Fatal("expected to return an error, but no error") | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestStorageRead(t *testing.T) { | ||
cases := []struct { | ||
desc string | ||
config *Config | ||
client Client | ||
contents []byte | ||
ok bool | ||
}{ | ||
{ | ||
desc: "simple", | ||
config: &Config{ | ||
Bucket: "tfmigrate-test", | ||
Name: "tfmigrate/history.json", | ||
}, | ||
client: &mockClient{ | ||
dataToRead: []byte("foo"), | ||
err: nil, | ||
}, | ||
contents: []byte("foo"), | ||
ok: true, | ||
}, | ||
{ | ||
desc: "bucket does not exist", | ||
config: &Config{ | ||
Bucket: "not-exist-bucket", | ||
Name: "tfmigrate/history.json", | ||
}, | ||
client: &mockClient{ | ||
dataToRead: nil, | ||
err: gcStorage.ErrBucketNotExist, | ||
}, | ||
contents: nil, | ||
ok: false, | ||
}, | ||
{ | ||
desc: "object does not exist", | ||
config: &Config{ | ||
Bucket: "tfmigrate-test", | ||
Name: "not_exist.json", | ||
}, | ||
client: &mockClient{ | ||
err: gcStorage.ErrObjectNotExist, | ||
}, | ||
contents: []byte{}, | ||
ok: true, | ||
}, | ||
} | ||
|
||
for _, tc := range cases { | ||
t.Run(tc.desc, func(t *testing.T) { | ||
s, err := NewStorage(tc.config, tc.client) | ||
if err != nil { | ||
t.Fatalf("failed to NewStorage: %s", err) | ||
} | ||
got, err := s.Read(context.Background()) | ||
if tc.ok && err != nil { | ||
t.Fatalf("unexpected err: %s", err) | ||
} | ||
if !tc.ok && err == nil { | ||
t.Fatal("expected to return an error, but no error") | ||
} | ||
|
||
if tc.ok { | ||
if string(got) != string(tc.contents) { | ||
t.Errorf("got: %s, want: %s", string(got), string(tc.contents)) | ||
} | ||
} | ||
}) | ||
} | ||
} |
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.
Add GoDoc style comments to all public interfaces, types, and methods.