From 21b942e8aebe04827785fe961d4c97fb8323f7ba Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Mon, 1 Jan 2024 04:00:31 +0900 Subject: [PATCH 1/2] introduce spare command --- .gitignore | 1 + Makefile | 5 +- README.md | 3 +- app/di/wire.go | 53 +++++ app/di/wire_gen.go | 72 +++++++ app/domain/model/domain.go | 98 +++++++++ app/domain/model/domain_test.go | 269 ++++++++++++++++++++++++ app/domain/model/errors.go | 6 + app/domain/model/s3.go | 78 +++++-- app/domain/model/s3_policy.go | 87 ++++++++ app/domain/model/s3_policy_test.go | 31 +++ app/domain/model/testdata/s3policy.json | 1 + app/domain/service/cloudfront.go | 41 ++++ app/domain/service/errors.go | 23 ++ app/domain/service/s3.go | 48 ++++- app/domain/service/s3_policy.go | 39 ++++ app/external/cloudfront.go | 144 +++++++++++++ app/external/common.go | 25 +++ app/external/s3.go | 108 +++++++++- app/external/s3_policy.go | 84 ++++++++ app/interactor/cloudfront.go | 59 ++++++ app/interactor/s3.go | 112 ++++++++++ app/usecase/cloudfront.go | 24 +++ app/usecase/s3.go | 30 ++- app/usecase/s3_policy.go | 39 ++++ cmd/spare/main.go | 16 ++ cmd/subcmd/s3hub/rm.go | 8 +- cmd/subcmd/spare/build.go | 148 +++++++++++++ cmd/subcmd/spare/common.go | 101 +++++++++ cmd/subcmd/spare/deploy.go | 119 +++++++++++ cmd/subcmd/spare/init.go | 57 +++++ cmd/subcmd/spare/root.go | 33 +++ cmd/subcmd/spare/version.go | 22 ++ config/errors.go | 20 ++ config/spare/config.go | 142 +++++++++++++ config/spare/config_test.go | 227 ++++++++++++++++++++ doc/img/s3_cloudfront.drawio | 78 +++++++ doc/img/s3_cloudfront.png | Bin 0 -> 58365 bytes doc/img/sample_spa.jpeg | Bin 0 -> 61186 bytes doc/spare/README.md | 89 ++++++++ go.mod | 33 ++- go.sum | 101 ++++++++- utils/file/file.go | 26 +++ 43 files changed, 2655 insertions(+), 45 deletions(-) create mode 100644 app/domain/model/domain.go create mode 100644 app/domain/model/domain_test.go create mode 100644 app/domain/model/s3_policy.go create mode 100644 app/domain/model/s3_policy_test.go create mode 100644 app/domain/model/testdata/s3policy.json create mode 100644 app/domain/service/cloudfront.go create mode 100644 app/domain/service/errors.go create mode 100644 app/domain/service/s3_policy.go create mode 100644 app/external/cloudfront.go create mode 100644 app/external/common.go create mode 100644 app/external/s3_policy.go create mode 100644 app/interactor/cloudfront.go create mode 100644 app/usecase/cloudfront.go create mode 100644 app/usecase/s3_policy.go create mode 100644 cmd/spare/main.go create mode 100644 cmd/subcmd/spare/build.go create mode 100644 cmd/subcmd/spare/common.go create mode 100644 cmd/subcmd/spare/deploy.go create mode 100644 cmd/subcmd/spare/init.go create mode 100644 cmd/subcmd/spare/root.go create mode 100644 cmd/subcmd/spare/version.go create mode 100644 config/errors.go create mode 100644 config/spare/config.go create mode 100644 config/spare/config_test.go create mode 100644 doc/img/s3_cloudfront.drawio create mode 100644 doc/img/s3_cloudfront.png create mode 100644 doc/img/sample_spa.jpeg create mode 100644 doc/spare/README.md create mode 100644 utils/file/file.go diff --git a/.gitignore b/.gitignore index 4281056..17504a3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ cover.* data localstack /s3hub +/spare diff --git a/Makefile b/Makefile index ad0d298..05db445 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: build test clean changelog tools help docker generate gif S3HUB = s3hub +SPARE = spare VERSION = $(shell git describe --tags --abbrev=0) GO = go GO_BUILD = $(GO) build @@ -16,9 +17,11 @@ GO_LDFLAGS = -ldflags '-X github.com/nao1215/rainbow/version.Version=${VERSION} build: ## Build binary env GO111MODULE=on GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO_BUILD) $(GO_LDFLAGS) -o $(S3HUB) cmd/s3hub/main.go + env GO111MODULE=on GOOS=$(GOOS) GOARCH=$(GOARCH) $(GO_BUILD) $(GO_LDFLAGS) -o $(SPARE) cmd/spare/main.go + clean: ## Clean project - -rm -rf $(S3HUB) cover.out cover.html + -rm -rf $(S3HUB) $(SPARE) cover.out cover.html test: ## Start unit test env GOOS=$(GOOS) $(GO_TEST) -cover $(GO_PKGROOT) -coverprofile=cover.out diff --git a/README.md b/README.md index ab0f0f3..81fe00d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ The rainbow project is a toolset for managing AWS resources. This project consis [WIP] |Name|README|implementation|Description| |:--|:--|:--|:--| -|[s3hub](./doc/s3hub/README.md)|✅||user-friendly s3 management tool| +|[s3hub](./doc/s3hub/README.md)|✅||User-friendly s3 management tool| +|[spare](./doc/spare/README.md)|✅||Single Page Application Release Easily| ### s3hub example #### Create a bucket(s) diff --git a/app/di/wire.go b/app/di/wire.go index f360b23..5aa43c8 100644 --- a/app/di/wire.go +++ b/app/di/wire.go @@ -64,3 +64,56 @@ func newS3App( S3BucketObjectsDeleter: s3BucketObjectsDeleter, } } + +// SpareApp is the application service for spare command. +type SpareApp struct { + // CloudFrontCreator is the usecase for creating CloudFront. + usecase.CloudFrontCreator + // FileUploader is the usecase for uploading a file. + usecase.FileUploader + // S3BucketCreator is the usecase for creating a new S3 bucket. + usecase.S3BucketCreator + // S3BucketPublicAccessBlocker is the usecase for blocking public access to a S3 bucket. + usecase.S3BucketPublicAccessBlocker + // BucketPolicySetter is the usecase for setting a bucket policy. + usecase.S3BucketPolicySetter +} + +// NewSpareApp creates a new SpareApp. +func NewSpareApp(ctx context.Context, profile model.AWSProfile, region model.Region) (*SpareApp, error) { + wire.Build( + model.NewAWSConfig, + external.NewCloudFrontClient, + external.CloudFrontCreatorSet, + external.OAICreatorSet, + external.NewS3Client, + external.S3BucketCreatorSet, + external.S3BucketObjectUploaderSet, + external.S3BucketPublicAccessBlockerSet, + external.S3BucketPolicySetterSet, + interactor.CloudFrontCreatorSet, + interactor.FileUploaderSet, + interactor.S3BucketCreatorSet, + interactor.S3BucketPublicAccessBlockerSet, + interactor.S3BucketPolicySetterSet, + newSpareApp, + ) + return nil, nil +} + +// newSpareApp creates a new SpareApp. +func newSpareApp( + cloudFrontCreator usecase.CloudFrontCreator, + fileUploader usecase.FileUploader, + s3BucketCreator usecase.S3BucketCreator, + s3BucketPublicAccessBlocker usecase.S3BucketPublicAccessBlocker, + s3BucketPolicySetter usecase.S3BucketPolicySetter, +) *SpareApp { + return &SpareApp{ + CloudFrontCreator: cloudFrontCreator, + FileUploader: fileUploader, + S3BucketCreator: s3BucketCreator, + S3BucketPublicAccessBlocker: s3BucketPublicAccessBlocker, + S3BucketPolicySetter: s3BucketPolicySetter, + } +} diff --git a/app/di/wire_gen.go b/app/di/wire_gen.go index 4c81028..c86bb32 100644 --- a/app/di/wire_gen.go +++ b/app/di/wire_gen.go @@ -41,6 +41,42 @@ func NewS3App(ctx context.Context, profile model.AWSProfile, region model.Region return s3App, nil } +// NewSpareApp creates a new SpareApp. +func NewSpareApp(ctx context.Context, profile model.AWSProfile, region model.Region) (*SpareApp, error) { + awsConfig, err := model.NewAWSConfig(ctx, profile, region) + if err != nil { + return nil, err + } + client, err := external.NewCloudFrontClient(awsConfig) + if err != nil { + return nil, err + } + cloudFrontCreator := external.NewCloudFrontCreator(client) + cloudFrontOAICreator := external.NewCloudFrontOAICreator(client) + cloudFrontCreatorOptions := &interactor.CloudFrontCreatorOptions{ + CloudFrontCreator: cloudFrontCreator, + OAICreator: cloudFrontOAICreator, + } + interactorCloudFrontCreator := interactor.NewCloudFrontCreator(cloudFrontCreatorOptions) + s3Client, err := external.NewS3Client(awsConfig) + if err != nil { + return nil, err + } + s3BucketObjectUploader := external.NewS3BucketObjectUploader(s3Client) + fileUploaderOptions := &interactor.FileUploaderOptions{ + S3BucketObjectUploader: s3BucketObjectUploader, + } + fileUploader := interactor.NewFileUploader(fileUploaderOptions) + s3BucketCreator := external.NewS3BucketCreator(s3Client) + interactorS3BucketCreator := interactor.NewS3BucketCreator(s3BucketCreator) + s3BucketPublicAccessBlocker := external.NewS3BucketPublicAccessBlocker(s3Client) + interactorS3BucketPublicAccessBlocker := interactor.NewS3BucketPublicAccessBlocker(s3BucketPublicAccessBlocker) + s3BucketPolicySetter := external.NewS3BucketPolicySetter(s3Client) + interactorS3BucketPolicySetter := interactor.NewS3BucketPolicySetter(s3BucketPolicySetter) + spareApp := newSpareApp(interactorCloudFrontCreator, fileUploader, interactorS3BucketCreator, interactorS3BucketPublicAccessBlocker, interactorS3BucketPolicySetter) + return spareApp, nil +} + // wire.go: // S3App is the application service for S3. @@ -77,3 +113,39 @@ func newS3App( S3BucketObjectsDeleter: s3BucketObjectsDeleter, } } + +// SpareApp is the application service for spare command. +type SpareApp struct { + usecase. + // CloudFrontCreator is the usecase for creating CloudFront. + CloudFrontCreator + usecase.FileUploader + usecase.S3BucketCreator + + // FileUploader is the usecase for uploading a file. + + // S3BucketCreator is the usecase for creating a new S3 bucket. + usecase.S3BucketPublicAccessBlocker + // S3BucketPublicAccessBlocker is the usecase for blocking public access to a S3 bucket. + usecase.S3BucketPolicySetter + + // BucketPolicySetter is the usecase for setting a bucket policy. + +} + +// newSpareApp creates a new SpareApp. +func newSpareApp( + cloudFrontCreator usecase.CloudFrontCreator, + fileUploader usecase.FileUploader, + s3BucketCreator usecase.S3BucketCreator, + s3BucketPublicAccessBlocker usecase.S3BucketPublicAccessBlocker, + s3BucketPolicySetter usecase.S3BucketPolicySetter, +) *SpareApp { + return &SpareApp{ + CloudFrontCreator: cloudFrontCreator, + FileUploader: fileUploader, + S3BucketCreator: s3BucketCreator, + S3BucketPublicAccessBlocker: s3BucketPublicAccessBlocker, + S3BucketPolicySetter: s3BucketPolicySetter, + } +} diff --git a/app/domain/model/domain.go b/app/domain/model/domain.go new file mode 100644 index 0000000..48e6493 --- /dev/null +++ b/app/domain/model/domain.go @@ -0,0 +1,98 @@ +package model + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nao1215/rainbow/utils/errfmt" +) + +// Domain is a type that represents a domain name. +type Domain string + +// String returns the string representation of Domain. +func (d Domain) String() string { + return string(d) +} + +// Validate validates Domain. If Domain is invalid, it returns an error. +// If domain is empty, it returns nil and the default CloudFront domain will be used. +func (d Domain) Validate() error { + for _, part := range strings.Split(d.String(), ".") { + if !isAlphaNumeric(part) { + return errfmt.Wrap(ErrInvalidDomain, fmt.Sprintf("domain %s is invalid", d)) + } + } + return nil +} + +// isAlphaNumeric returns true if s is alphanumeric. +func isAlphaNumeric(s string) bool { + for _, r := range s { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') { + return false + } + } + return true +} + +// Empty is whether domain is empty +func (d Domain) Empty() bool { + return d == "" +} + +// AllowOrigins is list of origins (domain names) that CloudFront can use as +// the value for the Access-Control-Allow-Origin HTTP response header. +type AllowOrigins []Domain + +// Validate validates AllowOrigins. If AllowOrigins is invalid, it returns an error. +func (a AllowOrigins) Validate() (err error) { + for _, origin := range a { + if e := origin.Validate(); e != nil { + err = errors.Join(err, e) + } + } + return err +} + +// String returns the string representation of AllowOrigins. +func (a AllowOrigins) String() string { + origins := make([]string, 0, len(a)) + for _, origin := range a { + if origin.Empty() { + continue + } + origins = append(origins, origin.String()) + } + return strings.Join(origins, ",") +} + +// Endpoint is a type that represents an endpoint. +type Endpoint string + +// String returns the string representation of Endpoint. +func (e Endpoint) String() string { + return string(e) +} + +// Validate validates Endpoint. If Endpoint is invalid, it returns an error. +func (e Endpoint) Validate() error { + if e == "" { + return errfmt.Wrap(ErrInvalidEndpoint, "endpoint is empty") + } + + parsedURL, err := url.Parse(e.String()) + if err != nil { + return errfmt.Wrap(ErrInvalidDomain, err.Error()) + } + host := parsedURL.Host + if host == "" || parsedURL.Scheme == "" { + return errfmt.Wrap(ErrInvalidDomain, host) + } + return nil +} + +// DebugLocalstackEndpoint is the endpoint for localstack. It's used for testing. +const DebugLocalstackEndpoint = "http://localhost:4566" diff --git a/app/domain/model/domain_test.go b/app/domain/model/domain_test.go new file mode 100644 index 0000000..75549a6 --- /dev/null +++ b/app/domain/model/domain_test.go @@ -0,0 +1,269 @@ +package model + +import ( + "errors" + "testing" +) + +const ( + exampleCom = "example.com" + exampleNet = "example.net" + exampleComWithProtocol = "https://example.com" +) + +func TestDomainString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d Domain + want string + }{ + { + name: exampleCom, + d: exampleCom, + want: exampleCom, + }, + } + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.d.String(); got != tt.want { + t.Errorf("Domain.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDomainValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d Domain + wantErr error + }{ + { + name: "success", + d: exampleCom, + wantErr: nil, + }, + { + name: "failure. protocol is included", + d: exampleComWithProtocol, + wantErr: ErrInvalidDomain, + }, + { + name: "success. domain is empty", + d: "", + wantErr: nil, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.d.Validate(); !errors.Is(err, tt.wantErr) { + t.Errorf("Domain.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDomainEmpty(t *testing.T) { + t.Parallel() + tests := []struct { + name string + d Domain + want bool + }{ + { + name: "success", + d: exampleCom, + want: false, + }, + { + name: "failure", + d: "", + want: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.d.Empty(); got != tt.want { + t.Errorf("Domain.Empty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsAlphaNumeric(t *testing.T) { + t.Parallel() + + type args struct { + s string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "success", + args: args{s: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"}, + want: true, + }, + { + name: "failure", + args: args{s: "abc123/"}, + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := isAlphaNumeric(tt.args.s); got != tt.want { + t.Errorf("isAlphaNumeric() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAllowOriginsValidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a AllowOrigins + wantErr bool + }{ + { + name: "success", + a: AllowOrigins{exampleCom, exampleNet}, + wantErr: false, + }, + { + name: "success. include empty string", + a: AllowOrigins{exampleCom, ""}, + wantErr: false, + }, + { + name: "failure. origin is invalid", + a: AllowOrigins{exampleCom, exampleComWithProtocol}, + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.a.Validate(); (err != nil) != tt.wantErr { + t.Errorf("AllowOrigins.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestEndpointString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + e Endpoint + want string + }{ + { + name: "success", + e: Endpoint(exampleComWithProtocol), + want: exampleComWithProtocol, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.e.String(); got != tt.want { + t.Errorf("Endpoint.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEndpointValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + e Endpoint + wantErr bool + }{ + { + name: "success", + e: Endpoint(exampleComWithProtocol), + wantErr: false, + }, + { + name: "failure. protocol is not included", + e: exampleCom, + wantErr: true, + }, + { + name: "failure. endpoint is empty", + e: "", + wantErr: true, + }, + { + name: "failure. include Ctrl character", + e: "\x00", + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.e.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Endpoint.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAllowOriginsString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + a AllowOrigins + want string + }{ + { + name: "success", + a: AllowOrigins{exampleCom, exampleNet}, + want: "example.com,example.net", + }, + { + name: "success. include empty string", + a: AllowOrigins{exampleCom, ""}, + want: "example.com", + }, + { + name: "success. empty", + a: AllowOrigins{}, + want: "", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.a.String(); got != tt.want { + t.Errorf("AllowOrigins.String() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/app/domain/model/errors.go b/app/domain/model/errors.go index ec57afc..1ca2321 100644 --- a/app/domain/model/errors.go +++ b/app/domain/model/errors.go @@ -9,4 +9,10 @@ var ( ErrEmptyRegion = errors.New("region is empty") // ErrInvalidBucketName is an error that occurs when the bucket name is invalid. ErrInvalidBucketName = errors.New("bucket name is invalid") + // ErrInvalidDomain is an error that occurs when the domain is invalid. + ErrInvalidDomain = errors.New("invalid domain") + // ErrNotDetectContentType is an error that occurs when the content type cannot be detected. + ErrNotDetectContentType = errors.New("failed to detect content type") + // ErrInvalidEndpoint is an error that occurs when the endpoint is invalid. + ErrInvalidEndpoint = errors.New("invalid endpoint") ) diff --git a/app/domain/model/s3.go b/app/domain/model/s3.go index f04d1c9..66f5d77 100644 --- a/app/domain/model/s3.go +++ b/app/domain/model/s3.go @@ -2,7 +2,11 @@ package model import ( + "bytes" "fmt" + "io/fs" + "os" + "path/filepath" "strings" "time" @@ -10,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/nao1215/rainbow/utils/errfmt" "github.com/nao1215/rainbow/utils/xregex" + "github.com/wailsapp/mimetype" ) const ( @@ -158,9 +163,23 @@ func (r Region) Prev() Region { return RegionAPNortheast1 } +const ( + // BucketMinLength is the minimum length of the bucket name. + BucketMinLength = 3 + // BucketMaxLength is the maximum length of the bucket name. + BucketMaxLength = 63 + // S3Protocol is the protocol of the S3 bucket. + S3Protocol = "s3://" +) + // Bucket is the name of the S3 bucket. type Bucket string +// NewBucketWithoutProtocol creates a new Bucket. +func NewBucketWithoutProtocol(s string) Bucket { + return Bucket(strings.TrimPrefix(s, S3Protocol)) +} + // String returns the string representation of the Bucket. func (b Bucket) String() string { return string(b) @@ -214,16 +233,9 @@ func (b Bucket) Validate() error { return nil } -const ( - // BucketMinLength is the minimum length of the bucket name. - BucketMinLength = 3 - // BucketMaxLength is the maximum length of the bucket name. - BucketMaxLength = 63 -) - // validateLength validates the length of the bucket name. func (b Bucket) validateLength() error { - if len(b) < 3 || len(b) > 63 { + if len(b) < BucketMinLength || len(b) > BucketMaxLength { return fmt.Errorf("s3 bucket name must be between 3 and 63 characters long") } return nil @@ -302,33 +314,33 @@ type BucketSet struct { CreationDate time.Time } -// S3ObjectSets is the set of the S3ObjectSet. -type S3ObjectSets []S3Object +// S3ObjectIdentifierSets is the set of the S3ObjectSet. +type S3ObjectIdentifierSets []S3ObjectIdentifier -// Len returns the length of the S3ObjectSets. -func (s S3ObjectSets) Len() int { +// Len returns the length of the S3ObjectIdentifierSets. +func (s S3ObjectIdentifierSets) Len() int { return len(s) } // ToS3ObjectIdentifiers converts the S3ObjectSets to the ObjectIdentifiers. -func (s S3ObjectSets) ToS3ObjectIdentifiers() []types.ObjectIdentifier { +func (s S3ObjectIdentifierSets) ToS3ObjectIdentifiers() []types.ObjectIdentifier { ids := make([]types.ObjectIdentifier, 0, s.Len()) for _, o := range s { - ids = append(ids, *o.ToS3ObjectIdentifier()) + ids = append(ids, *o.ToAWSS3ObjectIdentifier()) } return ids } -// S3Object is the object in the S3 bucket. -type S3Object struct { +// S3ObjectIdentifier is the object identifier in the S3 bucket. +type S3ObjectIdentifier struct { // S3Key is the name of the object. S3Key S3Key // VersionID is the version ID for the specific version of the object to delete. VersionID VersionID } -// ToS3ObjectIdentifier converts the S3Object to the ObjectIdentifier. -func (o S3Object) ToS3ObjectIdentifier() *types.ObjectIdentifier { +// ToAWSS3ObjectIdentifier converts the S3ObjectIdentifier to the ObjectIdentifier. +func (o S3ObjectIdentifier) ToAWSS3ObjectIdentifier() *types.ObjectIdentifier { return &types.ObjectIdentifier{ Key: aws.String(o.S3Key.String()), VersionId: aws.String(o.VersionID.String()), @@ -363,3 +375,33 @@ type VersionID string func (v VersionID) String() string { return string(v) } + +// S3Object is the object in the S3 bucket. +type S3Object struct { + *bytes.Buffer +} + +// NewS3Object creates a new S3Object. +func NewS3Object(b []byte) *S3Object { + return &S3Object{Buffer: bytes.NewBuffer(b)} +} + +// ToFile writes the S3Object to the file. +func (s *S3Object) ToFile(path string, perm fs.FileMode) error { + return os.WriteFile(filepath.Clean(path), s.Bytes(), perm) +} + +// ContentType returns the content type of the S3Object. +// If the content type cannot be detected, it returns "plain/text". +func (s *S3Object) ContentType() string { + mtype, err := mimetype.DetectReader(s.Buffer) + if err != nil { + return "plain/text" + } + return mtype.String() +} + +// ContentLength returns the content length of the S3Object. +func (s *S3Object) ContentLength() int64 { + return int64(s.Len()) +} diff --git a/app/domain/model/s3_policy.go b/app/domain/model/s3_policy.go new file mode 100644 index 0000000..4218138 --- /dev/null +++ b/app/domain/model/s3_policy.go @@ -0,0 +1,87 @@ +package model + +import ( + "encoding/json" + "fmt" + + "github.com/nao1215/rainbow/utils/errfmt" +) + +// Statement is a type that represents a statement. +type Statement struct { + // Sid is an identifier for the statement. + Sid string `json:"Sid"` //nolint + // Effect is whether the statement allows or denies access. + Effect string `json:"Effect"` //nolint + // Principal is the AWS account, IAM user, IAM role, federated user, or assumed-role user that the statement applies to. + Principal Principal `json:"Principal"` //nolint + // Action is the specific action or actions that will be allowed or denied. + Action []string `json:"Action"` //nolint + // Resource is the specific Amazon S3 resources that the statement covers. + Resource []string `json:"Resource"` //nolint + // The Condition element (or Condition block) lets you specify conditions for when a policy is in effect. + // https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html + Condition map[string]map[string]string `json:"Condition,omitempty"` //nolint +} + +// Principal is a type that represents a principal. +type Principal struct { + // Service is the AWS service to which the principal belongs. + Service string `json:"Service"` //nolint +} + +// BucketPolicy is a type that represents a bucket policy. +type BucketPolicy struct { + // Version is the policy language version. + Version string `json:"Version"` //nolint + // Statement is the policy statement. + Statement []Statement `json:"Statement"` //nolint +} + +// NewAllowCloudFrontS3BucketPolicy returns a new BucketPolicy that allows CloudFront to access the S3 bucket. +func NewAllowCloudFrontS3BucketPolicy(bucket Bucket) *BucketPolicy { + return &BucketPolicy{ + Version: "2012-10-17", + Statement: []Statement{ + { + Sid: "Allow CloudFront to GetObject", + Effect: "Allow", + Principal: Principal{Service: "cloudfront.amazonaws.com"}, + Action: []string{ + "s3:GetObject", + "s3:ListBucket", + }, + Resource: []string{ + fmt.Sprintf("arn::aws:s3:::%s", bucket.String()), + fmt.Sprintf("arn::aws:s3:::%s/*", bucket.String()), + }, + }, + { + Sid: "Secure Access", + Effect: "Deny", + Principal: Principal{Service: "*"}, + Action: []string{ + "s3:*", + }, + Resource: []string{ + fmt.Sprintf("arn::aws:s3:::%s", bucket.String()), + fmt.Sprintf("arn::aws:s3:::%s/*", bucket.String()), + }, + Condition: map[string]map[string]string{ + "Bool": { + "aws:SecureTransport": "false", + }, + }, + }, + }, + } +} + +// String returns the string representation of the BucketPolicy. +func (b *BucketPolicy) String() (string, error) { + policy, err := json.Marshal(b) + if err != nil { + return "", errfmt.Wrap(err, "failed to marshal bucket policy") + } + return string(policy), nil +} diff --git a/app/domain/model/s3_policy_test.go b/app/domain/model/s3_policy_test.go new file mode 100644 index 0000000..99a5429 --- /dev/null +++ b/app/domain/model/s3_policy_test.go @@ -0,0 +1,31 @@ +package model + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBucketPolicyString(t *testing.T) { + t.Parallel() + t.Run("output s3 policy for cloudfront", func(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(filepath.Join("testdata", "s3policy.json")) + if err != nil { + t.Fatal() + } + + bp := NewAllowCloudFrontS3BucketPolicy("bucket") + got, err := bp.String() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(string(data), got); diff != "" { + t.Errorf("value is mismatch (-want +got):\n%s", diff) + } + }) +} diff --git a/app/domain/model/testdata/s3policy.json b/app/domain/model/testdata/s3policy.json new file mode 100644 index 0000000..919280b --- /dev/null +++ b/app/domain/model/testdata/s3policy.json @@ -0,0 +1 @@ +{"Version":"2012-10-17","Statement":[{"Sid":"Allow CloudFront to GetObject","Effect":"Allow","Principal":{"Service":"cloudfront.amazonaws.com"},"Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn::aws:s3:::bucket","arn::aws:s3:::bucket/*"]},{"Sid":"Secure Access","Effect":"Deny","Principal":{"Service":"*"},"Action":["s3:*"],"Resource":["arn::aws:s3:::bucket","arn::aws:s3:::bucket/*"],"Condition":{"Bool":{"aws:SecureTransport":"false"}}}]} \ No newline at end of file diff --git a/app/domain/service/cloudfront.go b/app/domain/service/cloudfront.go new file mode 100644 index 0000000..33bfabe --- /dev/null +++ b/app/domain/service/cloudfront.go @@ -0,0 +1,41 @@ +package service + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// CloudFrontCreatorInput is an input struct for CDNCreator. +type CloudFrontCreatorInput struct { + // BucketName is the name of the bucket. + Bucket model.Bucket + // OAIID is the ID of the OAI. + OAIID *string +} + +// CloudFrontCreatorOutput is an output struct for CDNCreator. +type CloudFrontCreatorOutput struct { + // Domain is the domain of the CDN. + Domain model.Domain +} + +// CloudFrontCreator is an interface for creating CDN. +type CloudFrontCreator interface { + CreateCloudFront(context.Context, *CloudFrontCreatorInput) (*CloudFrontCreatorOutput, error) +} + +// OAICreatorInput is an input struct for OAICreator. +type OAICreatorInput struct{} + +// OAICreatorOutput is an output struct for OAICreator. +type OAICreatorOutput struct { + // ID is the ID of the OAI. + ID *string +} + +// OAICreator is an interface for creating OAI. +// OAI is an Origin Access Identity. +type OAICreator interface { + CreateOAI(context.Context, *OAICreatorInput) (*OAICreatorOutput, error) +} diff --git a/app/domain/service/errors.go b/app/domain/service/errors.go new file mode 100644 index 0000000..a07c5e5 --- /dev/null +++ b/app/domain/service/errors.go @@ -0,0 +1,23 @@ +// Package service is an abstraction layer for accessing external services. +package service + +import "errors" + +var ( + // ErrBucketAlreadyExistsOwnedByOther is an error that occurs when the bucket already exists and is owned by another account. + ErrBucketAlreadyExistsOwnedByOther = errors.New("bucket already exists and is owned by another account") + // ErrBucketAlreadyOwnedByYou is an error that occurs when the bucket already exists and is owned by you. + ErrBucketAlreadyOwnedByYou = errors.New("bucket already exists and is owned by you") + // ErrBucketPublicAccessBlock is an error that occurs when the bucket public access block setting fails. + ErrBucketPublicAccessBlock = errors.New("failed to set public access block") + // ErrBucketPolicySet is an error that occurs when the bucket policy setting fails. + ErrBucketPolicySet = errors.New("failed to set bucket policy") + // ErrCDNAlreadyExists is an error that occurs when the CDN already exists. + ErrCDNAlreadyExists = errors.New("CDN already exists") + // ErrOriginAccessIdentifyAlreadyExists is an error that occurs when the origin access identify already exists. + ErrOriginAccessIdentifyAlreadyExists = errors.New("origin access identify already exists") + // ErrNotDetectContentType is an error that occurs when the content type cannot be detected. + ErrNotDetectContentType = errors.New("failed to detect content type") + // ErrFileUpload is an error that occurs when the file upload fails. + ErrFileUpload = errors.New("failed to upload file") +) diff --git a/app/domain/service/s3.go b/app/domain/service/s3.go index a66227b..8a06c54 100644 --- a/app/domain/service/s3.go +++ b/app/domain/service/s3.go @@ -76,7 +76,7 @@ type S3BucketObjectsDeleterInput struct { // Region is the region of the bucket that you want to delete. Region model.Region // S3ObjectSets is the list of the objects to delete. - S3ObjectSets model.S3ObjectSets + S3ObjectSets model.S3ObjectIdentifierSets } // S3BucketObjectsDeleterOutput is the output of the DeleteBucketObjects method. @@ -96,10 +96,54 @@ type S3BucketObjectsListerInput struct { // S3BucketObjectsListerOutput is the output of the ListBucketObjects method. type S3BucketObjectsListerOutput struct { // Objects is the list of the objects. - Objects model.S3ObjectSets + Objects model.S3ObjectIdentifierSets } // S3BucketObjectsLister is the interface that wraps the basic ListBucketObjects method. type S3BucketObjectsLister interface { ListS3BucketObjects(ctx context.Context, input *S3BucketObjectsListerInput) (*S3BucketObjectsListerOutput, error) } + +// S3BucketObjectDownloaderInput is the input of the GetBucketObject method. +type S3BucketObjectDownloaderInput struct { + // Bucket is the name of the bucket to get. + Bucket model.Bucket + // S3Key is the key of the object to get. + S3Key model.S3Key +} + +// S3BucketObjectDownloaderOutput is the output of the GetBucketObject method. +type S3BucketObjectDownloaderOutput struct { + // S3Object is the object. + S3Object *model.S3Object +} + +// S3BucketObjectDownloader is the interface that wraps the basic GetBucketObject method. +type S3BucketObjectDownloader interface { + DownloadS3BucketObject(ctx context.Context, input *S3BucketObjectDownloaderInput) (*S3BucketObjectDownloaderOutput, error) +} + +// S3BucketObjectUploaderInput is the input of the PutBucketObject method. +type S3BucketObjectUploaderInput struct { + // Bucket is the name of the bucket to put. + Bucket model.Bucket + // Region is the region of the bucket that you want to put. + Region model.Region + // S3Key is the key of the object to put. + S3Key model.S3Key + // S3Object is the content of the object to put. + S3Object *model.S3Object +} + +// S3BucketObjectUploaderOutput is the output of the PutBucketObject method. +type S3BucketObjectUploaderOutput struct { + // ContentType is the content type of the object. + ContentType string + // ContentLength is the size of the object. + ContentLength int64 +} + +// S3BucketObjectUploader is the interface that wraps the basic PutBucketObject method. +type S3BucketObjectUploader interface { + UploadS3BucketObject(ctx context.Context, input *S3BucketObjectUploaderInput) (*S3BucketObjectUploaderOutput, error) +} diff --git a/app/domain/service/s3_policy.go b/app/domain/service/s3_policy.go new file mode 100644 index 0000000..f03b2a3 --- /dev/null +++ b/app/domain/service/s3_policy.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// S3BucketPublicAccessBlockerInput is an input struct for BucketAccessBlocker. +type S3BucketPublicAccessBlockerInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Region is the name of the region. + Region model.Region +} + +// S3BucketPublicAccessBlockerOutput is an output struct for BucketAccessBlocker. +type S3BucketPublicAccessBlockerOutput struct{} + +// S3BucketPublicAccessBlocker is an interface for blocking access to a bucket. +type S3BucketPublicAccessBlocker interface { + BlockS3BucketPublicAccess(context.Context, *S3BucketPublicAccessBlockerInput) (*S3BucketPublicAccessBlockerOutput, error) +} + +// S3BucketPolicySetterInput is an input struct for BucketPolicySetter. +type S3BucketPolicySetterInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Policy is the policy to set. + Policy *model.BucketPolicy +} + +// S3BucketPolicySetterOutput is an output struct for BucketPolicySetter. +type S3BucketPolicySetterOutput struct{} + +// S3BucketPolicySetter is an interface for setting a bucket policy. +type S3BucketPolicySetter interface { + SetS3BucketPolicy(context.Context, *S3BucketPolicySetterInput) (*S3BucketPolicySetterOutput, error) +} diff --git a/app/external/cloudfront.go b/app/external/cloudfront.go new file mode 100644 index 0000000..c77a83a --- /dev/null +++ b/app/external/cloudfront.go @@ -0,0 +1,144 @@ +package external + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudfront" + "github.com/aws/aws-sdk-go-v2/service/cloudfront/types" + "github.com/google/uuid" + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/domain/service" + "github.com/nao1215/rainbow/utils/errfmt" +) + +// NewCloudFrontClient returns a new CloudFront client. +func NewCloudFrontClient(cfg *model.AWSConfig) (*cloudfront.Client, error) { + return cloudfront.NewFromConfig(*cfg.Config), nil +} + +// CloudFrontCreatorSet is a provider set for CloudFrontCreator. +var CloudFrontCreatorSet = wire.NewSet( + wire.Bind(new(service.CloudFrontCreator), new(*CloudFrontCreator)), + NewCloudFrontCreator, +) + +// CloudFrontCreator is an implementation for CloudFrontCreator. +type CloudFrontCreator struct { + *cloudfront.Client +} + +var _ service.CloudFrontCreator = &CloudFrontCreator{} + +// NewCloudFrontCreator creates a new CloudFrontCreator. +func NewCloudFrontCreator(c *cloudfront.Client) *CloudFrontCreator { + return &CloudFrontCreator{ + Client: c, + } +} + +// CreateCloudFront creates a CDN. +func (c *CloudFrontCreator) CreateCloudFront(ctx context.Context, input *service.CloudFrontCreatorInput) (*service.CloudFrontCreatorOutput, error) { + createDistributionInput := &cloudfront.CreateDistributionInput{ + DistributionConfig: &types.DistributionConfig{ + Comment: aws.String("CloudFront Distribution Generated by Rainbow Project"), + CallerReference: aws.String(uuid.New().String()), + DefaultCacheBehavior: &types.DefaultCacheBehavior{ + TargetOriginId: aws.String("S3 Origin ID Generated by Rainbow Project"), + ViewerProtocolPolicy: types.ViewerProtocolPolicyRedirectToHttps, + MinTTL: aws.Int64(300), //nolint:gomnd + MaxTTL: aws.Int64(300), //nolint:gomnd + DefaultTTL: aws.Int64(300), //nolint:gomnd + AllowedMethods: &types.AllowedMethods{ + Items: []types.Method{ + types.Method("GET"), + types.Method("HEAD"), + types.Method("OPTIONS"), + }, + Quantity: aws.Int32(3), + CachedMethods: &types.CachedMethods{ + Items: []types.Method{ + types.Method("GET"), + types.Method("HEAD"), + }, + Quantity: aws.Int32(2), //nolint:gomnd + }, + }, + // Deprecated fields + ForwardedValues: &types.ForwardedValues{ + QueryString: aws.Bool(true), + Cookies: &types.CookiePreference{ + Forward: types.ItemSelection("none"), + }, + }, + }, + DefaultRootObject: aws.String("index.html"), + HttpVersion: types.HttpVersion("http2and3"), + PriceClass: types.PriceClass("PriceClass_100"), + Origins: &types.Origins{ + Items: []types.Origin{ + { + Id: aws.String("S3 Origin ID Generated by Spare"), + DomainName: aws.String(input.Bucket.Domain()), + S3OriginConfig: &types.S3OriginConfig{ + OriginAccessIdentity: aws.String( + fmt.Sprintf("origin-access-identity/cloudfront/%s", *input.OAIID), + ), + }, + }, + }, + Quantity: aws.Int32(1), + }, + Enabled: aws.Bool(true), + }, + } + + output, err := c.CreateDistribution(ctx, createDistributionInput) + if err != nil { + return nil, errfmt.Wrap(err, "failed to create a CloudFront distribution") + } + return &service.CloudFrontCreatorOutput{ + Domain: model.Domain(*output.Distribution.DomainName), + }, nil +} + +// OAICreatorSet is a provider set for OAICreator. +var OAICreatorSet = wire.NewSet( + NewCloudFrontOAICreator, + wire.Bind(new(service.OAICreator), new(*CloudFrontOAICreator)), +) + +// CloudFrontOAICreator is an implementation for OAICreator. +type CloudFrontOAICreator struct { + *cloudfront.Client +} + +var _ service.OAICreator = &CloudFrontOAICreator{} + +// NewCloudFrontOAICreator creates a new CloudFrontOAICreator. +func NewCloudFrontOAICreator(c *cloudfront.Client) *CloudFrontOAICreator { + return &CloudFrontOAICreator{ + Client: c, + } +} + +// CreateOAI creates a new OAI. +func (c *CloudFrontOAICreator) CreateOAI(ctx context.Context, _ *service.OAICreatorInput) (*service.OAICreatorOutput, error) { + createOAIInput := &cloudfront.CreateCloudFrontOriginAccessIdentityInput{ + CloudFrontOriginAccessIdentityConfig: &types.CloudFrontOriginAccessIdentityConfig{ + CallerReference: aws.String(uuid.NewString()), + Comment: aws.String("Origin Access Identity (OAI) Generated by Spare"), + }, + } + + output, err := c.CreateCloudFrontOriginAccessIdentity(ctx, createOAIInput) + if err != nil { + return nil, err + } + + return &service.OAICreatorOutput{ + ID: output.CloudFrontOriginAccessIdentity.Id, + }, nil +} diff --git a/app/external/common.go b/app/external/common.go new file mode 100644 index 0000000..b11f2b0 --- /dev/null +++ b/app/external/common.go @@ -0,0 +1,25 @@ +package external + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/nao1215/rainbow/app/domain/model" +) + +// newS3Session returns a new session. +func newS3Session(profile model.AWSProfile, region model.Region, endpoint *model.Endpoint) *session.Session { + session := session.Must(session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, // Ref. ~/.aws/config + Profile: profile.String(), + })) + + session.Config.Region = aws.String(region.String()) + if endpoint != nil { + // If you want to debug, uncomment the following lines. + // session.Config.WithLogLevel(aws.LogDebugWithHTTPBody) + session.Config.S3ForcePathStyle = aws.Bool(true) + session.Config.Endpoint = aws.String(endpoint.String()) + session.Config.DisableSSL = aws.Bool(true) + } + return session +} diff --git a/app/external/s3.go b/app/external/s3.go index a2a277c..9769d3d 100644 --- a/app/external/s3.go +++ b/app/external/s3.go @@ -3,7 +3,9 @@ package external import ( "context" + "errors" "fmt" + "io" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -240,7 +242,7 @@ func NewS3BucketObjectsLister(client *s3.Client) *S3BucketObjectsLister { // ListS3BucketObjects lists the objects in the bucket. func (c *S3BucketObjectsLister) ListS3BucketObjects(ctx context.Context, input *service.S3BucketObjectsListerInput) (*service.S3BucketObjectsListerOutput, error) { - var objects model.S3ObjectSets + var objects model.S3ObjectIdentifierSets in := &s3.ListObjectsV2Input{ Bucket: aws.String(input.Bucket.String()), MaxKeys: aws.Int32(model.MaxS3Keys), @@ -252,7 +254,7 @@ func (c *S3BucketObjectsLister) ListS3BucketObjects(ctx context.Context, input * } for _, o := range output.Contents { - objects = append(objects, model.S3Object{ + objects = append(objects, model.S3ObjectIdentifier{ S3Key: model.S3Key(*o.Key), }) } @@ -264,3 +266,105 @@ func (c *S3BucketObjectsLister) ListS3BucketObjects(ctx context.Context, input * } return &service.S3BucketObjectsListerOutput{Objects: objects}, nil } + +// S3BucketObjectDownloader implements the S3BucketObjectDownloader interface. +type S3BucketObjectDownloader struct { + client *s3.Client +} + +// S3BucketObjectDownloaderSet is a provider set for S3BucketObjectGetter. +// +//nolint:gochecknoglobals +var S3BucketObjectDownloaderSet = wire.NewSet( + NewS3BucketObjectDownloader, + wire.Bind(new(service.S3BucketObjectDownloader), new(*S3BucketObjectDownloader)), +) + +var _ service.S3BucketObjectDownloader = (*S3BucketObjectDownloader)(nil) + +// NewS3BucketObjectDownloader creates a new S3BucketObjectGetter. +func NewS3BucketObjectDownloader(client *s3.Client) *S3BucketObjectDownloader { + return &S3BucketObjectDownloader{client: client} +} + +// DownloadS3BucketObject gets the object in the bucket. +func (c *S3BucketObjectDownloader) DownloadS3BucketObject(ctx context.Context, input *service.S3BucketObjectDownloaderInput) (*service.S3BucketObjectDownloaderOutput, error) { + out, err := c.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(input.Bucket.String()), + Key: aws.String(input.S3Key.String()), + }) + if err != nil { + return nil, err + } + + body := out.Body + defer func() { + e := body.Close() + if e != nil { + err = errors.Join(err, e) + } + }() + + b, err := io.ReadAll(body) + if err != nil { + return nil, err + } + + return &service.S3BucketObjectDownloaderOutput{ + S3Object: model.NewS3Object(b), + }, nil +} + +// S3BucketObjectUploader implements the S3BucketObjectUploader interface. +type S3BucketObjectUploader struct { + client *s3.Client +} + +// S3BucketObjectUploaderSet is a provider set for S3BucketObjectUploader. +// +//nolint:gochecknoglobals +var S3BucketObjectUploaderSet = wire.NewSet( + NewS3BucketObjectUploader, + wire.Bind(new(service.S3BucketObjectUploader), new(*S3BucketObjectUploader)), +) + +var _ service.S3BucketObjectUploader = (*S3BucketObjectUploader)(nil) + +// NewS3BucketObjectUploader creates a new S3BucketObjectUploader. +func NewS3BucketObjectUploader(client *s3.Client) *S3BucketObjectUploader { + return &S3BucketObjectUploader{client: client} +} + +// UploadS3BucketObject puts the object in the bucket. +func (c *S3BucketObjectUploader) UploadS3BucketObject(ctx context.Context, input *service.S3BucketObjectUploaderInput) (*service.S3BucketObjectUploaderOutput, error) { + _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(input.Bucket.String()), + Key: aws.String(input.S3Key.String()), + Body: input.S3Object, + ContentType: aws.String(input.S3Object.ContentType()), + ContentLength: aws.Int64(input.S3Object.ContentLength()), + }) + if err != nil { + return nil, err + } + return &service.S3BucketObjectUploaderOutput{ + ContentType: input.S3Object.ContentType(), + ContentLength: input.S3Object.ContentLength(), + }, nil +} + +// BucketPublicAccessBlockerInput is an input struct for BucketAccessBlocker. +type BucketPublicAccessBlockerInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Region is the name of the region. + Region model.Region +} + +// BucketPublicAccessBlockerOutput is an output struct for BucketAccessBlocker. +type BucketPublicAccessBlockerOutput struct{} + +// BucketPublicAccessBlocker is an interface for blocking access to a bucket. +type BucketPublicAccessBlocker interface { + BlockBucketPublicAccess(context.Context, *BucketPublicAccessBlockerInput) (*BucketPublicAccessBlockerOutput, error) +} diff --git a/app/external/s3_policy.go b/app/external/s3_policy.go new file mode 100644 index 0000000..e721aab --- /dev/null +++ b/app/external/s3_policy.go @@ -0,0 +1,84 @@ +package external + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/service" + "github.com/nao1215/rainbow/utils/errfmt" +) + +// S3BucketPublicAccessBlockerSet is a provider set for BucketPublicAccessBlocker. +// +//nolint:gochecknoglobals +var S3BucketPublicAccessBlockerSet = wire.NewSet( + NewS3BucketPublicAccessBlocker, + wire.Bind(new(service.S3BucketPublicAccessBlocker), new(*S3BucketPublicAccessBlocker)), +) + +// S3BucketPublicAccessBlocker is an implementation for BucketPublicAccessBlocker. +type S3BucketPublicAccessBlocker struct { + client *s3.Client +} + +var _ service.S3BucketPublicAccessBlocker = &S3BucketPublicAccessBlocker{} + +// NewS3BucketPublicAccessBlocker returns a new S3BucketPublicAccessBlocker struct. +func NewS3BucketPublicAccessBlocker(client *s3.Client) *S3BucketPublicAccessBlocker { + return &S3BucketPublicAccessBlocker{client} +} + +// BlockS3BucketPublicAccess blocks public access to a bucket on S3. +func (s *S3BucketPublicAccessBlocker) BlockS3BucketPublicAccess(ctx context.Context, input *service.S3BucketPublicAccessBlockerInput) (*service.S3BucketPublicAccessBlockerOutput, error) { + if _, err := s.client.PutPublicAccessBlock(ctx, &s3.PutPublicAccessBlockInput{ + Bucket: aws.String(input.Bucket.String()), + PublicAccessBlockConfiguration: &types.PublicAccessBlockConfiguration{ + BlockPublicAcls: aws.Bool(true), + BlockPublicPolicy: aws.Bool(true), + IgnorePublicAcls: aws.Bool(true), + RestrictPublicBuckets: aws.Bool(true), + }, + }); err != nil { + return nil, errfmt.Wrap(service.ErrBucketPublicAccessBlock, err.Error()) + } + return &service.S3BucketPublicAccessBlockerOutput{}, nil +} + +// S3BucketPolicySetterSet is a provider set for BucketPolicySetter. +// +//nolint:gochecknoglobals +var S3BucketPolicySetterSet = wire.NewSet( + NewS3BucketPolicySetter, + wire.Bind(new(service.S3BucketPolicySetter), new(*S3BucketPolicySetter)), +) + +// S3BucketPolicySetter is an implementation for BucketPolicySetter. +type S3BucketPolicySetter struct { + client *s3.Client +} + +var _ service.S3BucketPolicySetter = &S3BucketPolicySetter{} + +// NewS3BucketPolicySetter returns a new S3BucketPolicySetter struct. +func NewS3BucketPolicySetter(client *s3.Client) *S3BucketPolicySetter { + return &S3BucketPolicySetter{client} +} + +// SetS3BucketPolicy sets a bucket policy on S3. +func (s *S3BucketPolicySetter) SetS3BucketPolicy(ctx context.Context, input *service.S3BucketPolicySetterInput) (*service.S3BucketPolicySetterOutput, error) { + policy, err := input.Policy.String() + if err != nil { + return nil, err + } + + if _, err = s.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{ + Bucket: aws.String(input.Bucket.String()), + Policy: aws.String(policy), + }); err != nil { + return nil, errfmt.Wrap(service.ErrBucketPolicySet, err.Error()) + } + return &service.S3BucketPolicySetterOutput{}, nil +} diff --git a/app/interactor/cloudfront.go b/app/interactor/cloudfront.go new file mode 100644 index 0000000..c47cfbc --- /dev/null +++ b/app/interactor/cloudfront.go @@ -0,0 +1,59 @@ +package interactor + +import ( + "context" + + "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/service" + + "github.com/nao1215/rainbow/app/usecase" +) + +// CloudFrontCreatorSet is a set of CloudFrontCreator. +// +//nolint:gochecknoglobals +var CloudFrontCreatorSet = wire.NewSet( + NewCloudFrontCreator, + wire.Struct(new(CloudFrontCreatorOptions), "*"), + wire.Bind(new(usecase.CloudFrontCreator), new(*CloudFrontCreator)), +) + +var _ usecase.CloudFrontCreator = (*CloudFrontCreator)(nil) + +// CloudFrontCreator is an implementation for CloudFrontCreator. +type CloudFrontCreator struct { + opts *CloudFrontCreatorOptions +} + +// CloudFrontCreatorOptions is an option struct for CloudFrontCreator. +type CloudFrontCreatorOptions struct { + service.CloudFrontCreator + service.OAICreator +} + +// NewCloudFrontCreator returns a new CloudFrontCreator struct. +func NewCloudFrontCreator(opts *CloudFrontCreatorOptions) *CloudFrontCreator { + return &CloudFrontCreator{ + opts: opts, + } +} + +// CreateCloudFront creates a CDN. +func (c *CloudFrontCreator) CreateCloudFront(ctx context.Context, input *usecase.CreateCloudFrontInput) (*usecase.CreateCloudFrontOutput, error) { + oaiOutput, err := c.opts.OAICreator.CreateOAI(ctx, &service.OAICreatorInput{}) + if err != nil { + return nil, err + } + + createCDNOutput, err := c.opts.CloudFrontCreator.CreateCloudFront(ctx, &service.CloudFrontCreatorInput{ + Bucket: input.Bucket, + OAIID: oaiOutput.ID, + }) + if err != nil { + return nil, err + } + + return &usecase.CreateCloudFrontOutput{ + Domain: createCDNOutput.Domain, + }, nil +} diff --git a/app/interactor/s3.go b/app/interactor/s3.go index 3a5bb90..4735382 100644 --- a/app/interactor/s3.go +++ b/app/interactor/s3.go @@ -5,6 +5,7 @@ import ( "context" "github.com/google/wire" + "github.com/nao1215/rainbow/app/domain/model" "github.com/nao1215/rainbow/app/domain/service" "github.com/nao1215/rainbow/app/usecase" ) @@ -232,3 +233,114 @@ func (s *S3BucketDeleter) DeleteS3Bucket(ctx context.Context, input *usecase.S3B } return &usecase.S3BucketDeleterOutput{}, nil } + +// FileUploaderSet is a provider set for FileUploader. +// +//nolint:gochecknoglobals +var FileUploaderSet = wire.NewSet( + NewFileUploader, + wire.Struct(new(FileUploaderOptions), "*"), + wire.Bind(new(usecase.FileUploader), new(*FileUploader)), +) + +var _ usecase.FileUploader = (*FileUploader)(nil) + +// FileUploader is an implementation for FileUploader. +type FileUploader struct { + opts *FileUploaderOptions +} + +// FileUploaderOptions is an option struct for FileUploader. +type FileUploaderOptions struct { + service.S3BucketObjectUploader +} + +// NewFileUploader returns a new FileUploader struct. +func NewFileUploader(opts *FileUploaderOptions) *FileUploader { + return &FileUploader{ + opts: opts, + } +} + +// UploadFile uploads a file to external storage. +func (u *FileUploader) UploadFile(ctx context.Context, input *usecase.UploadFileInput) (*usecase.UploadFileOutput, error) { + output, err := u.opts.S3BucketObjectUploader.UploadS3BucketObject(ctx, &service.S3BucketObjectUploaderInput{ + Bucket: input.Bucket, + Region: input.Region, + S3Key: input.Key, + S3Object: model.NewS3Object(input.Data), + }) + if err != nil { + return nil, err + } + return &usecase.UploadFileOutput{ + ContentType: output.ContentType, + ContentLength: output.ContentLength, + }, nil +} + +// S3BucketPublicAccessBlockerSet is a provider set for BucketPublicAccessBlocker. +// +//nolint:gochecknoglobals +var S3BucketPublicAccessBlockerSet = wire.NewSet( + NewS3BucketPublicAccessBlocker, + wire.Bind(new(usecase.S3BucketPublicAccessBlocker), new(*S3BucketPublicAccessBlocker)), +) + +// S3BucketPublicAccessBlocker is an implementation for BucketPublicAccessBlocker. +type S3BucketPublicAccessBlocker struct { + service.S3BucketPublicAccessBlocker +} + +var _ usecase.S3BucketPublicAccessBlocker = (*S3BucketPublicAccessBlocker)(nil) + +// NewS3BucketPublicAccessBlocker returns a new S3BucketPublicAccessBlocker struct. +func NewS3BucketPublicAccessBlocker(b service.S3BucketPublicAccessBlocker) *S3BucketPublicAccessBlocker { + return &S3BucketPublicAccessBlocker{ + S3BucketPublicAccessBlocker: b, + } +} + +// BlockS3BucketPublicAccess blocks public access to a bucket on S3. +func (s *S3BucketPublicAccessBlocker) BlockS3BucketPublicAccess(ctx context.Context, input *usecase.S3BucketPublicAccessBlockerInput) (*usecase.S3BucketPublicAccessBlockerOutput, error) { + if _, err := s.S3BucketPublicAccessBlocker.BlockS3BucketPublicAccess(ctx, &service.S3BucketPublicAccessBlockerInput{ + Bucket: input.Bucket, + Region: input.Region, + }); err != nil { + return nil, err + } + return &usecase.S3BucketPublicAccessBlockerOutput{}, nil +} + +// S3BucketPolicySetterSet is a provider set for BucketPolicySetter. +// +//nolint:gochecknoglobals +var S3BucketPolicySetterSet = wire.NewSet( + NewS3BucketPolicySetter, + wire.Bind(new(usecase.S3BucketPolicySetter), new(*S3BucketPolicySetter)), +) + +// S3BucketPolicySetter is an implementation for BucketPolicySetter. +type S3BucketPolicySetter struct { + service.S3BucketPolicySetter +} + +var _ usecase.S3BucketPolicySetter = (*S3BucketPolicySetter)(nil) + +// NewS3BucketPolicySetter returns a new S3BucketPolicySetter struct. +func NewS3BucketPolicySetter(s service.S3BucketPolicySetter) *S3BucketPolicySetter { + return &S3BucketPolicySetter{ + S3BucketPolicySetter: s, + } +} + +// SetS3BucketPolicy sets a bucket policy on S3. +func (s *S3BucketPolicySetter) SetS3BucketPolicy(ctx context.Context, input *usecase.S3BucketPolicySetterInput) (*usecase.S3BucketPolicySetterOutput, error) { + if _, err := s.S3BucketPolicySetter.SetS3BucketPolicy(ctx, &service.S3BucketPolicySetterInput{ + Bucket: input.Bucket, + Policy: input.Policy, + }); err != nil { + return nil, err + } + return &usecase.S3BucketPolicySetterOutput{}, nil +} diff --git a/app/usecase/cloudfront.go b/app/usecase/cloudfront.go new file mode 100644 index 0000000..fe32853 --- /dev/null +++ b/app/usecase/cloudfront.go @@ -0,0 +1,24 @@ +package usecase + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// CloudFrontCreator is an interface for creating CloudFront. +type CloudFrontCreator interface { + CreateCloudFront(ctx context.Context, input *CreateCloudFrontInput) (*CreateCloudFrontOutput, error) +} + +// CreateCloudFrontInput is an input struct for CloudFrontCreator. +type CreateCloudFrontInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket +} + +// CreateCloudFrontOutput is an output struct for CloudFrontCreator. +type CreateCloudFrontOutput struct { + // Domain is the domain of the CDN. + Domain model.Domain +} diff --git a/app/usecase/s3.go b/app/usecase/s3.go index beff4ff..cf8a5e5 100644 --- a/app/usecase/s3.go +++ b/app/usecase/s3.go @@ -46,7 +46,7 @@ type S3BucketObjectsListerInput struct { // S3BucketObjectsListerOutput is the output of the ListObjects method. type S3BucketObjectsListerOutput struct { // Objects is the list of the objects. - Objects model.S3ObjectSets + Objects model.S3ObjectIdentifierSets } // S3BucketObjectsLister is the interface that wraps the basic ListObjects method. @@ -73,7 +73,7 @@ type S3BucketObjectsDeleterInput struct { // Bucket is the name of the bucket that you want to delete. Bucket model.Bucket // S3ObjectSets is the list of the objects to delete. - S3ObjectSets model.S3ObjectSets + S3ObjectSets model.S3ObjectIdentifierSets } // S3BucketObjectsDeleterOutput is the output of the DeleteObjects method. @@ -83,3 +83,29 @@ type S3BucketObjectsDeleterOutput struct{} type S3BucketObjectsDeleter interface { DeleteS3BucketObjects(ctx context.Context, input *S3BucketObjectsDeleterInput) (*S3BucketObjectsDeleterOutput, error) } + +// FileUploader is an interface for uploading files to external storage. +type FileUploader interface { + // UploadFile uploads a file from external storage. + UploadFile(ctx context.Context, input *UploadFileInput) (*UploadFileOutput, error) +} + +// UploadFileInput is an input struct for FileUploader. +type UploadFileInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Region is the name of the region where the bucket is located. + Region model.Region + // Key is the S3 key + Key model.S3Key + // Data is the data to upload. + Data []byte +} + +// UploadFileOutput is an output struct for FileUploader. +type UploadFileOutput struct { + // ContentType is the content type of the uploaded file. + ContentType string + // ContentLength is the content length of the uploaded file. + ContentLength int64 +} diff --git a/app/usecase/s3_policy.go b/app/usecase/s3_policy.go new file mode 100644 index 0000000..10f3e0a --- /dev/null +++ b/app/usecase/s3_policy.go @@ -0,0 +1,39 @@ +package usecase + +import ( + "context" + + "github.com/nao1215/rainbow/app/domain/model" +) + +// S3BucketPublicAccessBlockerInput is the input of the BlockBucketPublicAccess method. +type S3BucketPublicAccessBlockerInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Region is the name of the region. + Region model.Region +} + +// S3BucketPublicAccessBlockerOutput is the output of the BlockBucketPublicAccess method. +type S3BucketPublicAccessBlockerOutput struct{} + +// S3BucketPublicAccessBlocker is the interface that wraps the basic BlockBucketPublicAccess method. +type S3BucketPublicAccessBlocker interface { + BlockS3BucketPublicAccess(ctx context.Context, input *S3BucketPublicAccessBlockerInput) (*S3BucketPublicAccessBlockerOutput, error) +} + +// S3BucketPolicySetterInput is the input of the SetBucketPolicy method. +type S3BucketPolicySetterInput struct { + // Bucket is the name of the bucket. + Bucket model.Bucket + // Policy is the policy to set. + Policy *model.BucketPolicy +} + +// S3BucketPolicySetterOutput is an output struct for BucketPolicySetter. +type S3BucketPolicySetterOutput struct{} + +// S3BucketPolicySetter is an interface for setting a bucket policy. +type S3BucketPolicySetter interface { + SetS3BucketPolicy(context.Context, *S3BucketPolicySetterInput) (*S3BucketPolicySetterOutput, error) +} diff --git a/cmd/spare/main.go b/cmd/spare/main.go new file mode 100644 index 0000000..a51cfc3 --- /dev/null +++ b/cmd/spare/main.go @@ -0,0 +1,16 @@ +// Package main is the entrypoint of s3hub. +package main + +import ( + "fmt" + "os" + + "github.com/nao1215/rainbow/cmd/subcmd/spare" +) + +func main() { + if err := spare.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} diff --git a/cmd/subcmd/s3hub/rm.go b/cmd/subcmd/s3hub/rm.go index 0026e46..a1aa8c2 100644 --- a/cmd/subcmd/s3hub/rm.go +++ b/cmd/subcmd/s3hub/rm.go @@ -135,8 +135,8 @@ func (r *rmCmd) remove(bucket model.Bucket, key model.S3Key) error { func (r *rmCmd) removeObject(bucket model.Bucket, key model.S3Key) error { if _, err := r.S3App.S3BucketObjectsDeleter.DeleteS3BucketObjects(r.ctx, &usecase.S3BucketObjectsDeleterInput{ Bucket: bucket, - S3ObjectSets: model.S3ObjectSets{ - model.S3Object{ + S3ObjectSets: model.S3ObjectIdentifierSets{ + model.S3ObjectIdentifier{ S3Key: key, }, }, @@ -190,8 +190,8 @@ func (r *rmCmd) removeObjects(bucket model.Bucket) error { } // divideIntoChunks divides a slice into chunks of the specified size. -func (r *rmCmd) divideIntoChunks(slice []model.S3Object, chunkSize int) [][]model.S3Object { - var chunks [][]model.S3Object +func (r *rmCmd) divideIntoChunks(slice []model.S3ObjectIdentifier, chunkSize int) [][]model.S3ObjectIdentifier { + var chunks [][]model.S3ObjectIdentifier for i := 0; i < len(slice); i += chunkSize { end := i + chunkSize diff --git a/cmd/subcmd/spare/build.go b/cmd/subcmd/spare/build.go new file mode 100644 index 0000000..09506e6 --- /dev/null +++ b/cmd/subcmd/spare/build.go @@ -0,0 +1,148 @@ +package spare + +import ( + "context" + "errors" + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/charmbracelet/log" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/usecase" + "github.com/nao1215/rainbow/cmd/subcmd" + "github.com/nao1215/rainbow/config/spare" + "github.com/nao1215/spare/config" + + "github.com/spf13/cobra" +) + +// newBuildCmd return build sub command. +func newBuildCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "build", + Short: "build AWS infrastructure for SPA", + Example: " spare build", + RunE: func(cmd *cobra.Command, args []string) error { + return subcmd.Run(cmd, args, &buildCmd{}) + }, + } + cmd.Flags().BoolP("debug", "d", false, "run debug mode. you must run localstack before using this flag") + cmd.Flags().StringP("profile", "p", "", "AWS profile name. if this is empty, use $AWS_PROFILE") + cmd.Flags().StringP("file", "f", config.ConfigFilePath, "config file path") + return cmd +} + +type buildCmd struct { + // ctx is a context.Context. + ctx context.Context + // spare is a struct that executes the build command. + spare *di.SpareApp + // config is a struct that contains the settings for the spare CLI command. + config *spare.Config + // configFilePath is a path of the config file. + configFilePath string + // debug is a flag that indicates whether to run debug mode. + debug bool + // awsProfile is a profile name of AWS. If this is empty, use $AWS_PROFILE. + awsProfile model.AWSProfile +} + +// Parse parses the arguments and flags. +func (b *buildCmd) Parse(cmd *cobra.Command, _ []string) (err error) { + spareOption := newSpareOption() + if err := spareOption.parseCommon(cmd, nil); err != nil { + return err + } + + b.ctx = spareOption.ctx + b.spare = spareOption.spare + b.config = spareOption.config + b.configFilePath = spareOption.configFilePath + b.debug = spareOption.debug + b.awsProfile = spareOption.awsProfile + + return nil +} + +// Do generate .spare.yml at current directory. +// If .spare.yml already exists, return error. +func (b *buildCmd) Do() error { + log.Info(fmt.Sprintf("[VALIDATE] check %s", b.configFilePath)) + if err := b.config.Validate(b.debug); err != nil { + return err + } + log.Info(fmt.Sprintf("[VALIDATE] ok %s", b.configFilePath)) + + if err := b.confirm(); err != nil { + return err + } + + log.Info("[ CREATE ] start building AWS infrastructure") + log.Info("[ CREATE ] s3 bucket with public access block policy", "name", b.config.S3Bucket.String()) + if _, err := b.spare.S3BucketCreator.CreateS3Bucket(b.ctx, &usecase.S3BucketCreatorInput{ + Bucket: b.config.S3Bucket, + Region: b.config.Region, + }); err != nil { + return err + } + if _, err := b.spare.S3BucketPublicAccessBlocker.BlockS3BucketPublicAccess(b.ctx, &usecase.S3BucketPublicAccessBlockerInput{ + Bucket: b.config.S3Bucket, + Region: b.config.Region, + }); err != nil { + return err + } + if _, err := b.spare.S3BucketPolicySetter.SetS3BucketPolicy(b.ctx, &usecase.S3BucketPolicySetterInput{ + Bucket: b.config.S3Bucket, + Policy: model.NewAllowCloudFrontS3BucketPolicy(b.config.S3Bucket), + }); err != nil { + return err + } + + log.Info("[ CREATE ] cloudfront distribution") + createCDNOutput, err := b.spare.CloudFrontCreator.CreateCloudFront(b.ctx, &usecase.CreateCloudFrontInput{ + Bucket: b.config.S3Bucket, + }) + if err != nil { + return err + } + log.Info("[ CREATE ] cloudfront distribution", "domain", createCDNOutput.Domain.String()) + + return nil +} + +// confirm shows the settings and asks if you want to build AWS infrastructure. +func (b *buildCmd) confirm() error { + log.Info("[CONFIRM ] check the settings") + fmt.Println("") + fmt.Println("[debug mode]") + fmt.Printf(" %t\n", b.debug) + fmt.Println("[aws profile]") + fmt.Printf(" %s\n", b.awsProfile.String()) + fmt.Printf("[%s]\n", b.configFilePath) + fmt.Printf(" spareTemplateVersion: %s\n", b.config.SpareTemplateVersion) + fmt.Printf(" deployTarget: %s\n", b.config.DeployTarget) + fmt.Printf(" region: %s\n", b.config.Region) + fmt.Printf(" customDomain: %s\n", b.config.CustomDomain) + fmt.Printf(" s3BucketName: %s\n", b.config.S3Bucket) + fmt.Printf(" allowOrigins: %s\n", b.config.AllowOrigins.String()) + if b.debug { + fmt.Printf(" debugLocalstackEndpoint: %s\n", b.config.DebugLocalstackEndpoint) + } + fmt.Println("") + + var result bool + if err := survey.AskOne( + &survey.Confirm{ + Message: "want to build AWS infrastructure with the above settings?", + }, + &result, + ); err != nil { + return err + } + + if !result { + return errors.New("canceled") + } + return nil +} diff --git a/cmd/subcmd/spare/common.go b/cmd/subcmd/spare/common.go new file mode 100644 index 0000000..7dd2684 --- /dev/null +++ b/cmd/subcmd/spare/common.go @@ -0,0 +1,101 @@ +package spare + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/config/spare" + "github.com/nao1215/rainbow/utils/errfmt" + "github.com/spf13/cobra" +) + +type spareOption struct { + // command is the cobra command. + command *cobra.Command + // ctx is a context.Context. + ctx context.Context + // spare is a struct that executes the sub command. + spare *di.SpareApp + // config is a struct that contains the settings for the spare CLI command. + config *spare.Config + // debug is a flag that indicates whether to run debug mode. + debug bool + // configFilePath is a path of the config file. + configFilePath string + // awsProfile is a profile name of AWS. If this is empty, use $AWS_PROFILE. + awsProfile model.AWSProfile +} + +// newSpareOption returns a new spareOption. +func newSpareOption() *spareOption { + return &spareOption{} +} + +// Parse parses the arguments and flags. +func (s *spareOption) parseCommon(cmd *cobra.Command, _ []string) error { + s.ctx = context.Background() + s.command = cmd + + debug, err := cmd.Flags().GetBool("debug") + if err != nil { + return errfmt.Wrap(err, "can not parse command line argument (--debug)") + } + s.debug = debug + + configFilePath, err := cmd.Flags().GetString("file") + if err != nil { + return errfmt.Wrap(err, "can not parse command line argument (--file)") + } + if configFilePath == "" { + configFilePath = spare.ConfigFilePath + } + s.configFilePath = configFilePath + + profile, err := cmd.Flags().GetString("profile") + if err != nil { + return errfmt.Wrap(err, "can not parse command line argument (--profile)") + } + s.awsProfile = model.NewAWSProfile(profile) + + if err := s.readConfig(configFilePath); err != nil { + return err + } + + spare, err := di.NewSpareApp(s.ctx, s.awsProfile, s.config.Region) + if err != nil { + return err + } + s.spare = spare + + return nil +} + +// readConfig reads .spare.yml and returns config.Config. +func (s *spareOption) readConfig(configFilePath string) error { + file, err := os.Open(filepath.Clean(configFilePath)) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + + cfg := spare.NewConfig() + if err := cfg.Read(file); err != nil { + return err + } + s.config = cfg + + return nil +} + +// commandName returns the s3hub command name. +func commandName() string { + return "spare" +} diff --git a/cmd/subcmd/spare/deploy.go b/cmd/subcmd/spare/deploy.go new file mode 100644 index 0000000..c1c4dff --- /dev/null +++ b/cmd/subcmd/spare/deploy.go @@ -0,0 +1,119 @@ +package spare + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/charmbracelet/log" + "github.com/nao1215/rainbow/app/di" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/app/usecase" + "github.com/nao1215/rainbow/cmd/subcmd" + "github.com/nao1215/rainbow/config/spare" + "github.com/nao1215/rainbow/utils/file" + + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" +) + +// newDeployCmd return deploy sub command. +func newDeployCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy", + Short: "deploy SPA to AWS", + Example: " spare deploy", + RunE: func(cmd *cobra.Command, args []string) error { + return subcmd.Run(cmd, args, &deployCmd{}) + }, + } + cmd.Flags().BoolP("debug", "d", false, "run debug mode. you must run localstack before using this flag") + cmd.Flags().StringP("profile", "p", "", "AWS profile name. if this is empty, use $AWS_PROFILE") + cmd.Flags().StringP("file", "f", spare.ConfigFilePath, "config file path") + return cmd +} + +type deployCmd struct { + // ctx is a context.Context. + ctx context.Context + // spare is a struct that executes the deploy command. + spare *di.SpareApp + // config is a struct that contains the settings for the spare CLI command. + config *spare.Config + // debug is a flag that indicates whether to run debug mode. + debug bool + // awsProfile is a profile name of AWS. If this is empty, use $AWS_PROFILE. + awsProfile model.AWSProfile +} + +// Parse parses the arguments and flags. +func (d *deployCmd) Parse(cmd *cobra.Command, _ []string) (err error) { + spareOption := newSpareOption() + if err := spareOption.parseCommon(cmd, nil); err != nil { + return err + } + + d.ctx = spareOption.ctx + d.spare = spareOption.spare + d.config = spareOption.config + d.debug = spareOption.debug + d.awsProfile = spareOption.awsProfile + + return nil +} + +// Do deploy SPA to AWS. +func (d *deployCmd) Do() error { + log.Info("[ MODE ]", "debug", d.debug) + log.Info("[ CONFIG ]", "profile", d.awsProfile) + log.Info("[ DEPLOY ]", "target path", d.config.DeployTarget, "bucket name", d.config.S3Bucket) + + files, err := file.WalkDir(d.config.DeployTarget.String()) + if err != nil { + return err + } + + eg, ctx := errgroup.WithContext(d.ctx) + weighted := semaphore.NewWeighted(int64(runtime.NumCPU())) + for _, file := range files { + file := file + eg.Go(func() error { + if err := weighted.Acquire(ctx, 1); err != nil { + return err + } + defer weighted.Release(1) + + return d.uploadFile(ctx, file) + }) + } + + if err := eg.Wait(); err != nil { + return err + } + return nil +} + +// uploadFile uploads a file to S3. +func (d *deployCmd) uploadFile(ctx context.Context, file string) error { + data, err := os.ReadFile(filepath.Clean(file)) + if err != nil { + return err + } + + key := strings.Replace(file, d.config.DeployTarget.String()+string(filepath.Separator), "", 1) + output, err := d.spare.FileUploader.UploadFile(ctx, &usecase.UploadFileInput{ + Bucket: d.config.S3Bucket, + Region: d.config.Region, + // e.g. src/index.html -> index.html + Key: model.S3Key(key), + Data: data, // TODO: change io.Reader? + }) + if err != nil { + return err + } + log.Info("[ DEPLOY ]", "file name", key, "mimetype", output.ContentType, "content length", output.ContentLength) + return nil +} diff --git a/cmd/subcmd/spare/init.go b/cmd/subcmd/spare/init.go new file mode 100644 index 0000000..a6af0e0 --- /dev/null +++ b/cmd/subcmd/spare/init.go @@ -0,0 +1,57 @@ +package spare + +import ( + "errors" + "os" + + "github.com/charmbracelet/log" + "github.com/nao1215/gorky/file" + "github.com/nao1215/rainbow/cmd/subcmd" + "github.com/nao1215/spare/config" + "github.com/spf13/cobra" +) + +// newInitCmd return init sub command. +func newInitCmd() *cobra.Command { + return &cobra.Command{ + Use: "init", + Short: "Generate .spare.yml at current directory", + Example: " spare init", + RunE: func(cmd *cobra.Command, args []string) error { + return subcmd.Run(cmd, args, &initCmd{}) + }, + } +} + +type initCmd struct{} + +// Parse parses the arguments and flags. +func (i *initCmd) Parse(_ *cobra.Command, _ []string) error { + return nil +} + +// Do generate .spare.yml at current directory. +// If .spare.yml already exists, return error. +func (i *initCmd) Do() error { + if file.IsFile(config.ConfigFilePath) { + return config.ErrConfigFileAlreadyExists + } + + file, err := os.Create(config.ConfigFilePath) + if err != nil { + return err + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + + if err := config.NewConfig().Write(file); err != nil { + return err + } + log.Info("[ CREATE ]", "config file name", config.ConfigFilePath) + log.Info("[ INFO ] If you need to change the setting values, please refer to the documentation") + // TODO: add link to documentation + return nil +} diff --git a/cmd/subcmd/spare/root.go b/cmd/subcmd/spare/root.go new file mode 100644 index 0000000..861c40d --- /dev/null +++ b/cmd/subcmd/spare/root.go @@ -0,0 +1,33 @@ +// Package spare is a package that contains subcommands for the spare CLI command. +package spare + +import ( + "github.com/spf13/cobra" +) + +// Execute starts the root command of s3hub. +func Execute() error { + if err := newRootCmd().Execute(); err != nil { + return err + } + return nil +} + +// newRootCmd creates a new root command. This command is the entry point of the CLI. +// It is responsible for parsing the command line arguments and flags, and then +// executing the appropriate subcommand. It also sets up logging and error handling. +// The root command does not have any functionality of its own. +func newRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "spare", + Short: "spare release single page application and aws infrastructure", + } + cmd.CompletionOptions.DisableDefaultCmd = true + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.AddCommand(newVersionCmd()) + cmd.AddCommand(newInitCmd()) + cmd.AddCommand(newBuildCmd()) + cmd.AddCommand(newDeployCmd()) + return cmd +} diff --git a/cmd/subcmd/spare/version.go b/cmd/subcmd/spare/version.go new file mode 100644 index 0000000..87b3d1c --- /dev/null +++ b/cmd/subcmd/spare/version.go @@ -0,0 +1,22 @@ +package spare + +import ( + "fmt" + + ver "github.com/nao1215/rainbow/version" + "github.com/spf13/cobra" +) + +// newVersionCmd return version command. +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: fmt.Sprintf("Print %s version", commandName()), + Run: version, + } +} + +// version return s3hub command version. +func version(cmd *cobra.Command, _ []string) { + cmd.Printf("%s %s (under MIT LICENSE)\n", commandName(), ver.GetVersion()) +} diff --git a/config/errors.go b/config/errors.go new file mode 100644 index 0000000..147ca60 --- /dev/null +++ b/config/errors.go @@ -0,0 +1,20 @@ +// Package config defines the config of rainbow cli. +package config + +import ( + "errors" + "fmt" +) + +var ( + // ErrConfigFileAlreadyExists is an error that occurs when the config file already exists. + ErrConfigFileAlreadyExists = fmt.Errorf("config file already exists") + // ErrInvalidRegion is an error that occurs when the region is invalid. + ErrInvalidRegion = errors.New("invalid region") + // ErrInvalidBucket is an error that occurs when the bucket is invalid. + ErrInvalidBucket = errors.New("invalid bucket") + // ErrInvalidSpareTemplateVersion is an error that occurs when the spare template version is invalid. + ErrInvalidSpareTemplateVersion = errors.New("invalid spare template version") + // ErrInvalidDeployTarget is an error that occurs when the deploy target is invalid. + ErrInvalidDeployTarget = errors.New("invalid deploy target") +) diff --git a/config/spare/config.go b/config/spare/config.go new file mode 100644 index 0000000..ef95a28 --- /dev/null +++ b/config/spare/config.go @@ -0,0 +1,142 @@ +package spare + +import ( + "errors" + "fmt" + "io" + + "github.com/charmbracelet/log" + "github.com/k1LoW/runn/version" + "github.com/nao1215/rainbow/app/domain/model" + "github.com/nao1215/rainbow/config" + "github.com/nao1215/rainbow/utils/errfmt" + "github.com/nao1215/spare/utils/xrand" + "gopkg.in/yaml.v2" +) + +// ConfigFilePath is the path of the configuration file. +const ConfigFilePath string = ".spare.yml" + +// Config is a struct that corresponds to the configuration file ".spare.yml". +type Config struct { + SpareTemplateVersion TemplateVersion `yaml:"spareTemplateVersion"` + // DeployTarget is the path of the deploy target (it's SPA). + DeployTarget DeployTarget `yaml:"deployTarget"` + // Region is AWS region. + Region model.Region `yaml:"region"` + // CustomDomain is the domain name of the CloudFront. + // If you do not specify this, the CloudFront default domain name is used. + CustomDomain model.Domain `yaml:"customDomain"` + // S3Bucket is the name of the S3 bucket. + S3Bucket model.Bucket `yaml:"s3BucketName"` + // AllowOrigins is the list of domains that are allowed to access the SPA. + AllowOrigins model.AllowOrigins `yaml:"allowOrigins"` + DebugLocalstackEndpoint model.Endpoint `yaml:"debugLocalstackEndpoint"` + // TODO: WAF, HTTPS, Cache +} + +// NewConfig returns a new Config. +func NewConfig() *Config { + cfg := &Config{ + SpareTemplateVersion: CurrentSpareTemplateVersion, + DeployTarget: "src", + Region: model.RegionUSEast1, + CustomDomain: "", + S3Bucket: "", + AllowOrigins: model.AllowOrigins{}, + DebugLocalstackEndpoint: model.DebugLocalstackEndpoint, + } + cfg.S3Bucket = cfg.DefaultS3BucketName() + return cfg +} + +// DefaultS3BucketName returns the default S3 bucket name. +func (c *Config) DefaultS3BucketName() model.Bucket { + const randomStrLen = 15 + randomID, err := xrand.RandomLowerAlphanumericStr(randomStrLen) + if err != nil { + log.Error(err) + randomID = "cannot-generate-random-id" + } + + return model.Bucket( + fmt.Sprintf("%s-%s-%s", version.Name, c.Region, randomID)) +} + +// Write writes the Config to the io.Writer. +func (c *Config) Write(w io.Writer) (err error) { + encoder := yaml.NewEncoder(w) + defer func() { + if closeErr := encoder.Close(); closeErr != nil { + err = errors.Join(err, closeErr) + } + }() + return encoder.Encode(c) +} + +// Read reads the Config from the io.Reader. +func (c *Config) Read(r io.Reader) (err error) { + decoder := yaml.NewDecoder(r) + return decoder.Decode(c) +} + +// Validate validates the Config. +// If debugMode is true, it validates the DebugLocalstackEndpoint. +func (c *Config) Validate(debugMode bool) error { + validators := []model.Validator{ + c.SpareTemplateVersion, + c.DeployTarget, + c.Region, + c.CustomDomain, + c.S3Bucket, + c.AllowOrigins, + } + if debugMode { + validators = append(validators, c.DebugLocalstackEndpoint) + } + + for _, v := range validators { + if err := v.Validate(); err != nil { + return err + } + } + return nil +} + +// TemplateVersion is a type that represents a spare template version. +type TemplateVersion string + +// CurrentSpareTemplateVersion is the version of the template. +const CurrentSpareTemplateVersion TemplateVersion = "0.0.1" + +// String returns the string representation of TemplateVersion. +func (t TemplateVersion) String() string { + return string(t) +} + +// Validate validates TemplateVersion. If TemplateVersion is invalid, it returns an error. +// TemplateVersion is invalid if it is empty. +func (t TemplateVersion) Validate() error { + if t == "" { + return errfmt.Wrap(config.ErrInvalidSpareTemplateVersion, "SpareTemplateVersion is empty") + } + return nil +} + +// DeployTarget is a type that represents a deploy target path. +type DeployTarget string + +// String returns the string representation of DeployTarget. +func (d DeployTarget) String() string { + return string(d) +} + +// Validate validates DeployTarget. If DeployTarget is invalid, it returns an error. +// DeployTarget is invalid if it is empty. +func (d DeployTarget) Validate() error { + if d == "" { + return errfmt.Wrap(config.ErrInvalidDeployTarget, "DeployTarget is empty") + } + // TODO: check if the path exists + return nil +} diff --git a/config/spare/config_test.go b/config/spare/config_test.go new file mode 100644 index 0000000..1ad5316 --- /dev/null +++ b/config/spare/config_test.go @@ -0,0 +1,227 @@ +package spare + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/nao1215/rainbow/app/domain/model" +) + +const ( + exampleCom = "example.com" + exampleComWithTestSubDomain = "test.example.com" + exampleComWithProtocol = "https://example.com" + testBucketName = "test-bucket" +) + +func TestConfigWrite(t *testing.T) { + t.Parallel() + + t.Run("success to write yml data", func(t *testing.T) { + t.Parallel() + + c := NewConfig() + c.S3Bucket = "" // to ignore random string + testFile := filepath.Join("testdata", "test.yml") + if runtime.GOOS == "windows" { + testFile = filepath.Join("testdata", "test_windows.yml") + } + + want, err := os.ReadFile(filepath.Clean(testFile)) + if err != nil { + t.Fatal(err) + } + + got := bytes.NewBufferString("") + if err := c.Write(got); err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(strings.ReplaceAll(got.String(), "\r", ""), strings.ReplaceAll(string(want), "\r", "")); diff != "" { + t.Errorf("value is mismatch (-want +got):\n%s", diff) + } + }) +} + +func TestConfigRead(t *testing.T) { + t.Parallel() + + t.Run("success to read yml data", func(t *testing.T) { + t.Parallel() + + file, err := os.Open(filepath.Join("testdata", "read_test.yml")) + if err != nil { + t.Fatal(err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil { + t.Fatal(closeErr) + } + }() + + got := NewConfig() + if err := got.Read(file); err != nil { + t.Fatal(err) + } + + want := &Config{ + SpareTemplateVersion: "1.0.0", + DeployTarget: "test-src", + Region: model.RegionUSEast2, + CustomDomain: exampleCom, + S3Bucket: testBucketName, + AllowOrigins: model.AllowOrigins{exampleCom, exampleComWithTestSubDomain}, + DebugLocalstackEndpoint: model.DebugLocalstackEndpoint, + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("value is mismatch (-want +got):\n%s", diff) + } + }) +} + +func TestConfigValidate(t *testing.T) { + t.Parallel() + type fields struct { + SpareTemplateVersion TemplateVersion + DeployTarget DeployTarget + Region model.Region + CustomDomain model.Domain + S3BucketName model.Bucket + AllowOrigins model.AllowOrigins + Endpoint model.Endpoint + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + { + name: "success", + fields: fields{ + SpareTemplateVersion: "1.0.0", + DeployTarget: "src", + Region: model.RegionUSEast1, + CustomDomain: exampleCom, + S3BucketName: testBucketName, + AllowOrigins: model.AllowOrigins{exampleCom, exampleComWithTestSubDomain}, + Endpoint: model.DebugLocalstackEndpoint, + }, + wantErr: false, + }, + { + name: "failure. SpareTemplateVersion is empty", + fields: fields{ + SpareTemplateVersion: "", + DeployTarget: "src", + Region: model.RegionUSEast1, + CustomDomain: exampleCom, + S3BucketName: testBucketName, + AllowOrigins: model.AllowOrigins{exampleCom, exampleComWithTestSubDomain}, + Endpoint: model.DebugLocalstackEndpoint, + }, + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Config{ + SpareTemplateVersion: tt.fields.SpareTemplateVersion, + DeployTarget: tt.fields.DeployTarget, + Region: tt.fields.Region, + CustomDomain: tt.fields.CustomDomain, + S3Bucket: tt.fields.S3BucketName, + AllowOrigins: tt.fields.AllowOrigins, + DebugLocalstackEndpoint: tt.fields.Endpoint, + } + if err := c.Validate(false); (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTemplateVersionString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tr TemplateVersion + want string + }{ + { + name: "0.0.1", + tr: "0.0.1", + want: "0.0.1", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.tr.String(); got != tt.want { + t.Errorf("TemplateVersion.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDeployTargetString(t *testing.T) { + t.Parallel() + tests := []struct { + name string + d DeployTarget + want string + }{ + { + name: "src", + d: "src", + want: "src", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := tt.d.String(); got != tt.want { + t.Errorf("DeployTarget.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDeployTargetValidate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + d DeployTarget + wantErr bool + }{ + { + name: "success", + d: "src", + wantErr: false, + }, + { + name: "failure. deploy target is empty", + d: "", + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.d.Validate(); (err != nil) != tt.wantErr { + t.Errorf("DeployTarget.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/doc/img/s3_cloudfront.drawio b/doc/img/s3_cloudfront.drawio new file mode 100644 index 0000000..15f1c44 --- /dev/null +++ b/doc/img/s3_cloudfront.drawio @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/doc/img/s3_cloudfront.png b/doc/img/s3_cloudfront.png new file mode 100644 index 0000000000000000000000000000000000000000..2b0b193d5f664ddca851452787282926b5ef95c2 GIT binary patch literal 58365 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 xoHlm-yMfw>IFEb%vnV-({;+t~iKJ>@ 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

T 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 literal 0 HcmV?d00001 diff --git a/doc/img/sample_spa.jpeg b/doc/img/sample_spa.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8af8c3ed04ad64caa3626a133a23e8b222e7685f GIT binary patch literal 61186 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 OKmvf(K!az>fB)G7f%BmN literal 0 HcmV?d00001 diff --git a/doc/spare/README.md b/doc/spare/README.md new file mode 100644 index 0000000..3c6de6d --- /dev/null +++ b/doc/spare/README.md @@ -0,0 +1,89 @@ +# spare - Single Page Application Release Easily +The 'spare' command makes easily the release of Single Page Applications. Spare constructs the infrastructure on AWS to operate the SPA, and then deploys the SPA (please note that it does not support building the SPA). Developers can inspect the infrastructure as CloudFormation before or after its construction. + +The infrastructure for S3 and CloudFront is configured as shown in the diagram when you run the "spare build" command. + +![diagram](../../doc/img/s3_cloudfront.png) + + +When you run "spare deploy," it uploads the SPA (Single Page Application) from the specified directory to S3. The diagram below represents a sample SPA delivered by CloudFront. Please note that the "spare" command does not perform TypeScript compilation or any other build steps. It only handles the deployment of your files to S3. +![sample-spa](../../doc/img/sample_spa.jpeg) + + +## How to install +### Use "go install" +If you does not have the golang development environment installed on your system, please install golang from [the golang official website](https://go.dev/doc/install). +```bash +go install github.com/nao1215/spare@latest +``` +## How to use +### init subcommand +init subcommand create the configuration file .spare.yml in the current directory. If you want to change the configuration file name, please use the edit subcommand. + +Below is the .spare.yml file created by the 'init' subcommand. As it's currently under development, the parameters will continue to change. +```.spare.yml +spareTemplateVersion: 0.0.1 +deployTarget: src +region: us-east-1 +customDomain: "" +s3BucketName: spare-us-east-1-ukdzd41mdfch7e6 +allowOrigins: [] +debugLocalstackEndpoint: http://localhost:4566 +``` + +| Key | Default Value | Description | +|:--------------------------------|:---------------|:-----------------------------------------------------------------------------------------------| +| `spareTemplateVersion` | "0.0.1" | The version of the Spare template. Unavailable. | +| `deployTarget` | src | The path of the deployment target (SPA). | +| `region` | us-east-1| The AWS region. | +| `customDomain` | "" | The domain name for CloudFront. If not specified, the CloudFront default domain name is used. Unavailable. | +| `s3BucketName` | spare-{REGION}-{RANDOM_ID} | The name of the S3 bucket. | +| `allowOrigins` | "" | The list of domains allowed to access the SPA. Unavailable. | +| `debugLocalstackEndpoint` | http://localhost:4566 | The endpoint for debugging Localstack. |* + +### build subcommand +The 'build' subcommand constructs the AWS infrastructure. + +```bash +$ spare build --debug +2023/09/02 17:28:18 INFO [VALIDATE] check .spare.yml +2023/09/02 17:28:18 INFO [VALIDATE] ok .spare.yml +2023/09/02 17:28:18 INFO [CONFIRM ] check the settings + +[debug mode] + true +[aws profile] + localstack +[.spare.yml] + spareTemplateVersion: 0.0.1 + deployTarget: testdata + region: ap-northeast-1 + customDomain: + s3BucketName: spare-northeast-2q21wk200dunjsem + allowOrigins: + debugLocalstackEndpoint: http://localhost:4566 + +? want to build AWS infrastructure with the above settings? Yes +2023/09/02 17:28:20 INFO [ CREATE ] start building AWS infrastructure +2023/09/02 17:28:20 INFO [ CREATE ] s3 bucket with public access block policy name=spare-northeast-2q21wk200dunjsem +2023/09/02 17:28:20 INFO [ CREATE ] cloudfront distribution +2023/09/02 17:28:20 INFO [ CREATE ] cloudfront distribution domain=localhost:4516 +``` + +### deploy subcommand +The 'deploy' subcommand uploads the built artifacts to the S3 bucket. +```bash +$ spare deploy --debug +2023/09/02 17:29:01 INFO [ MODE ] debug=true +2023/09/02 17:29:01 INFO [ CONFIG ] profile=localstack +2023/09/02 17:29:01 INFO [ DEPLOY ] target path=testdata bucket name=spare-northeast-2q21wk200dunjsem +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=images/why3.png +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=why.html +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=css/responsive.css +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=about.html +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=css/font-awesome.min.css +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=contact.html +2023/09/02 17:29:01 INFO [ DEPLOY ] file name=js/custom.js + : + : +``` diff --git a/go.mod b/go.mod index bf41ea7..8e76431 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,33 @@ module github.com/nao1215/rainbow -go 1.19 +go 1.21.0 + +toolchain go1.21.5 require ( + github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/aws/aws-sdk-go v1.49.13 github.com/aws/aws-sdk-go-v2 v1.24.0 github.com/aws/aws-sdk-go-v2/config v1.26.2 + github.com/aws/aws-sdk-go-v2/service/cloudfront v1.32.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.47.7 github.com/charmbracelet/bubbles v0.17.1 github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/log v0.3.1 github.com/fatih/color v1.16.0 github.com/google/go-cmp v0.6.0 + github.com/google/uuid v1.5.0 github.com/google/wire v0.5.0 + github.com/k1LoW/runn v0.92.0 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.15.2 + github.com/nao1215/gorky v0.2.1 + github.com/nao1215/spare v0.0.2 github.com/schollz/progressbar/v3 v3.14.1 github.com/spf13/cobra v1.8.0 + github.com/wailsapp/mimetype v1.4.1 golang.org/x/sync v0.5.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -36,24 +48,33 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.26.6 // indirect github.com/aws/smithy-go v1.19.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/caarlos0/env/v9 v9.0.0 // indirect github.com/charmbracelet/lipgloss v0.9.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/google/subcommands v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kr/pretty v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/mod v0.8.0 // indirect - golang.org/x/sys v0.14.0 // indirect - golang.org/x/term v0.14.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/tools v0.6.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index 8ccba9e..b43b38e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.49.13 h1:f4mGztsgnx2dR9r8FQYa9YW/RsKb+N7bgef4UGrOW1Y= +github.com/aws/aws-sdk-go v1.49.13/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= @@ -18,6 +24,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsM github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.32.5 h1:synDXYpTr5FA80g8twNr49Dd7iAKnxerp93l/kNm/cQ= +github.com/aws/aws-sdk-go-v2/service/cloudfront v1.32.5/go.mod h1:Dil6nVeCPyPc1gF5EeCrVUTtXexn80MpfqhgSp/Zb64= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= @@ -38,34 +46,61 @@ github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= +github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/log v0.3.1 h1:TjuY4OBNbxmHWSwO3tosgqs5I3biyY8sQPny/eCMTYw= +github.com/charmbracelet/log v0.3.1/go.mod h1:OR4E1hutLsax3ZKpXbgUqPtTjQfrh1pG3zwHGWuuq8g= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/k1LoW/runn v0.92.0 h1:GvRSb3XlajzNW2NGTspP12Gd8WEGnUbMd7EkhPTMZqU= +github.com/k1LoW/runn v0.92.0/go.mod h1:eW5rUMbdQIr9DIh5AMIkMLQVbIzNrFYBJZ4plPDJiF4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -74,6 +109,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= @@ -84,6 +121,10 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/nao1215/gorky v0.2.1 h1:kxXYhCNBbtGru9CCSYx+QC0JZfZJ1csY3uLbb5n2WKA= +github.com/nao1215/gorky v0.2.1/go.mod h1:fJNLiXzn3YkteARC8xghfHjkt+C5xtHOaRgmVnJEMOs= +github.com/nao1215/spare v0.0.2 h1:bZNKutQZfg+v7KX+6w9EvvTvY0ySD4gZa+uXRHEQZMM= +github.com/nao1215/spare v0.0.2/go.mod h1:2907GiSM1IWhSKt+kgLV4tUtlOI53dMEU1nwR0SLw0E= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -98,27 +139,69 @@ github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyh github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utils/file/file.go b/utils/file/file.go new file mode 100644 index 0000000..495a74e --- /dev/null +++ b/utils/file/file.go @@ -0,0 +1,26 @@ +// Package file provides functions for file operations. +package file + +import ( + "io/fs" + "path/filepath" + + "github.com/nao1215/spare/utils/errfmt" +) + +// WalkDir returns a list of files in the specified directory. +func WalkDir(rootDir string) ([]string, error) { + files := make([]string, 0) + + err := filepath.WalkDir(rootDir, func(path string, info fs.DirEntry, err error) error { + if err != nil { + return errfmt.Wrap(err, "failed to walk directory") + } + if info.IsDir() { + return nil + } + files = append(files, path) + return nil + }) + return files, err +} From 48bf68adca36e23ed1a1a929aa43716b798cfba5 Mon Sep 17 00:00:00 2001 From: CHIKAMATSU Naohiro Date: Mon, 1 Jan 2024 04:06:44 +0900 Subject: [PATCH 2/2] fix broken test and reviewdog --- .gitleaksignore | 3 ++- app/external/common.go | 25 ------------------------- config/spare/config.go | 2 +- config/spare/testdata/read_test.yml | 7 +++++++ config/spare/testdata/test.yml | 7 +++++++ config/spare/testdata/test_windows.yml | 7 +++++++ 6 files changed, 24 insertions(+), 27 deletions(-) delete mode 100644 app/external/common.go create mode 100644 config/spare/testdata/read_test.yml create mode 100644 config/spare/testdata/test.yml create mode 100644 config/spare/testdata/test_windows.yml diff --git a/.gitleaksignore b/.gitleaksignore index 905debd..8a112a1 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,3 @@ 389697647cbf4df63a2d2949f648216025355763:localstack/cache/server.test.pem.key:private-key:1 -389697647cbf4df63a2d2949f648216025355763:localstack/cache/server.test.pem:private-key:1 \ No newline at end of file +389697647cbf4df63a2d2949f648216025355763:localstack/cache/server.test.pem:private-key:1 +21b942e8aebe04827785fe961d4c97fb8323f7ba:doc/spare/README.md:generic-api-key:68 \ No newline at end of file diff --git a/app/external/common.go b/app/external/common.go deleted file mode 100644 index b11f2b0..0000000 --- a/app/external/common.go +++ /dev/null @@ -1,25 +0,0 @@ -package external - -import ( - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/nao1215/rainbow/app/domain/model" -) - -// newS3Session returns a new session. -func newS3Session(profile model.AWSProfile, region model.Region, endpoint *model.Endpoint) *session.Session { - session := session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, // Ref. ~/.aws/config - Profile: profile.String(), - })) - - session.Config.Region = aws.String(region.String()) - if endpoint != nil { - // If you want to debug, uncomment the following lines. - // session.Config.WithLogLevel(aws.LogDebugWithHTTPBody) - session.Config.S3ForcePathStyle = aws.Bool(true) - session.Config.Endpoint = aws.String(endpoint.String()) - session.Config.DisableSSL = aws.Bool(true) - } - return session -} diff --git a/config/spare/config.go b/config/spare/config.go index ef95a28..2269b6b 100644 --- a/config/spare/config.go +++ b/config/spare/config.go @@ -28,7 +28,7 @@ type Config struct { // If you do not specify this, the CloudFront default domain name is used. CustomDomain model.Domain `yaml:"customDomain"` // S3Bucket is the name of the S3 bucket. - S3Bucket model.Bucket `yaml:"s3BucketName"` + S3Bucket model.Bucket `yaml:"s3BucketName"` //nolint // AllowOrigins is the list of domains that are allowed to access the SPA. AllowOrigins model.AllowOrigins `yaml:"allowOrigins"` DebugLocalstackEndpoint model.Endpoint `yaml:"debugLocalstackEndpoint"` diff --git a/config/spare/testdata/read_test.yml b/config/spare/testdata/read_test.yml new file mode 100644 index 0000000..10cf96e --- /dev/null +++ b/config/spare/testdata/read_test.yml @@ -0,0 +1,7 @@ +spareTemplateVersion: 1.0.0 +deployTarget: test-src +region: us-east-2 +customDomain: "example.com" +s3BucketName: "test-bucket" +allowOrigins: ["example.com", "test.example.com"] +debugLocalstackEndpoint: http://localhost:4566 diff --git a/config/spare/testdata/test.yml b/config/spare/testdata/test.yml new file mode 100644 index 0000000..030d379 --- /dev/null +++ b/config/spare/testdata/test.yml @@ -0,0 +1,7 @@ +spareTemplateVersion: 0.0.1 +deployTarget: src +region: us-east-1 +customDomain: "" +s3BucketName: "" +allowOrigins: [] +debugLocalstackEndpoint: http://localhost:4566 diff --git a/config/spare/testdata/test_windows.yml b/config/spare/testdata/test_windows.yml new file mode 100644 index 0000000..b689ed5 --- /dev/null +++ b/config/spare/testdata/test_windows.yml @@ -0,0 +1,7 @@ +spareTemplateVersion: 0.0.1 +deployTarget: src +region: us-east-1 +customDomain: "" +s3BucketName: "" +allowOrigins: [] +debugLocalstackEndpoint: http://localhost:4566