Skip to content

Commit

Permalink
feat: add a simple GCS storage impl
Browse files Browse the repository at this point in the history
  • Loading branch information
KengoTODA committed Aug 19, 2022
1 parent d683647 commit ba92a6c
Show file tree
Hide file tree
Showing 7 changed files with 996 additions and 0 deletions.
61 changes: 61 additions & 0 deletions gcs/client.go
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)
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()
}

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)
if err != nil {
return nil, err
}
}

c, err := gcStorage.NewClient(ctx)
a := &Adapter{
config: config,
client: c,
}
return a, err
}
25 changes: 25 additions & 0 deletions gcs/config.go
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)
}
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)
}
})
}
}
64 changes: 64 additions & 0 deletions gcs/storage.go
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,
}
}

r, err := s.client.Read(ctx)
if err == gcStorage.ErrObjectNotExist {
return []byte{}, nil
} else if err != nil {
return nil, err
}
return r, 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

0 comments on commit ba92a6c

Please sign in to comment.