Skip to content
This repository has been archived by the owner on Aug 15, 2023. It is now read-only.

Add a simple GCS storage impl #3

Merged
merged 5 commits into from
Aug 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions gcs/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package gcs

import (
"context"
"fmt"
"io"

gcStorage "cloud.google.com/go/storage"
)

// A minimal interface to mock behavior of GCS client.
type Client interface {
Copy link
Owner

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.

// Read an object from a GCS bucket.
Read(ctx context.Context) ([]byte, error)

// Write an object onto a GCS bucket.
Write(ctx context.Context, p []byte) error
}

// An implementation of Client that delegates actual operation to gcsStorage.Client.
type Adapter struct {
// A config to specify which bucket and object we handle.
config Config
// A GCS client which is delegated actual operation.
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 := io.ReadAll(r)
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()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to use defer w.Close() to close the Writer on error. Is there any reason to implement it differently than Read?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is because:

  1. We do not care the error returned by r.Close() because io.readAll(r) has already handled its responsibility. In other words, after io.readAll(r) worked properlly, we do not care that r.Close() returns error or not.
  2. We do care the error returned by w.Close(), because it may mean that we failed to write/flush data to storage (GCS in this case). Even when w.Write(...) completed successfully, it may buffer the written data internally so we cannot say it is successfully saved onto the storage.

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()

Copy link
Owner

Choose a reason for hiding this comment

The 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.

}

// NewClient returns a new Client with given Context and Config.
func NewClient(ctx context.Context, config Config) (Client, error) {
c, err := gcStorage.NewClient(ctx)
a := &Adapter{
config: config,
client: c,
}
return a, err
}
23 changes: 23 additions & 0 deletions gcs/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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"`
}

// 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)
}
35 changes: 35 additions & 0 deletions gcs/config_test.go
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)
}
})
}
}
67 changes: 67 additions & 0 deletions gcs/storage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package gcs

import (
"context"

gcStorage "cloud.google.com/go/storage"
storage "github.com/minamijoyo/tfmigrate-storage"
)

// An implementation of [storage.Storage] interface.
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 {
err := s.init(ctx)
if err != nil {
return err
}

return s.client.Write(ctx, b)
}

func (s *Storage) Read(ctx context.Context) ([]byte, error) {
err := s.init(ctx)
if err != nil {
return nil, err
}
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
}

func (s *Storage) init(ctx context.Context) error {
if s.client == nil {
client, err := gcStorage.NewClient(ctx)
if err != nil {
return err
}
s.client = Adapter{
config: *s.config,
client: client,
}
}
return nil
}
144 changes: 144 additions & 0 deletions gcs/storage_test.go
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))
}
}
})
}
}
21 changes: 21 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,35 @@ module github.com/minamijoyo/tfmigrate-storage
go 1.17

require (
cloud.google.com/go/storage v1.25.0
github.com/aws/aws-sdk-go v1.43.22
github.com/hashicorp/aws-sdk-go-base v1.1.0
)

require (
cloud.google.com/go v0.102.1 // indirect
cloud.google.com/go/compute v1.7.0 // indirect
cloud.google.com/go/iam v0.3.0 // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.8 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.0 // indirect
github.com/hashicorp/go-multierror v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 // indirect
golang.org/x/sys v0.0.0-20220624220833-87e55d714810 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
google.golang.org/api v0.88.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220720214146-176da50484ac // indirect
google.golang.org/grpc v1.48.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
)
Loading