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/.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/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/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..2269b6b --- /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"` //nolint + // 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/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 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 0000000..2b0b193 Binary files /dev/null and b/doc/img/s3_cloudfront.png differ diff --git a/doc/img/sample_spa.jpeg b/doc/img/sample_spa.jpeg new file mode 100644 index 0000000..8af8c3e Binary files /dev/null and b/doc/img/sample_spa.jpeg differ 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 +}