diff --git a/.golangci.yml b/.golangci.yml index 4841ca9..5ea7704 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -62,6 +62,8 @@ linters: text: "G704:" - linters: [gosec] text: "G117:" + - linters: [gosec] + text: "G703:" # Global exclusions for staticcheck style suggestions - linters: [staticcheck] text: "SA1019:" diff --git a/conf.example.yml b/conf.example.yml index 274ec36..1a159b4 100644 --- a/conf.example.yml +++ b/conf.example.yml @@ -275,6 +275,7 @@ destination: - endpoint: somewhere:9000 # whatever your s3 endpoint is structured: true # checks repos out like hostersite/user|organization/repo bucket: your-bucket-name + use_static_creds: true # if true, use static credentials (accesskey/secretkey/token); if false, use IAM instance credentials (e.g. for AWS EC2/ECS) accesskey: your-access-key # can be an environment variable, just don't add a $ in front of it secretkey: your-secret-key # can be an environment variable, just don't add a $ in front of it token: your-token # can be an environment variable, just don't add a $ in front of it diff --git a/gickup_spec.json b/gickup_spec.json index 41e8c1c..92a375b 100644 --- a/gickup_spec.json +++ b/gickup_spec.json @@ -531,6 +531,10 @@ "type": "string", "description": "The bucket to store the backups in" }, + "use_static_creds": { + "type": "boolean", + "description": "If true, use static credentials (accesskey/secretkey/token). If false, use IAM instance credentials (e.g. for AWS EC2/ECS). Default: false" + }, "accesskey": { "type": "string", "description": "The access key to authenticate against the S3 server" diff --git a/main.go b/main.go index fd35426..3c368dc 100644 --- a/main.go +++ b/main.go @@ -56,7 +56,7 @@ var version = "unknown" func readConfigFile(configfile string) []*types.Conf { conf := []*types.Conf{} - cfgdata, err := os.Open(configfile) + cfgdata, err := os.Open(filepath.Clean(configfile)) if err != nil { log.Fatal(). Str("stage", "readconfig"). @@ -245,20 +245,22 @@ func backup(repos []types.Repo, conf *types.Conf) { } } - // Check if environment variables are used for accesskey and secretkey - d.AccessKey, err = d.GetKey(d.AccessKey) - if err != nil { - log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) - } - d.SecretKey, err = d.GetKey(d.SecretKey) - if err != nil { - log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) - } - if d.Token != "" { - d.Token, err = d.GetKey(d.Token) + if d.UseStaticCreds { + // Check if environment variables are used for accesskey and secretkey + d.AccessKey, err = d.GetKey(d.AccessKey) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } + d.SecretKey, err = d.GetKey(d.SecretKey) + if err != nil { + log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) + } + if d.Token != "" { + d.Token, err = d.GetKey(d.Token) + if err != nil { + log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) + } + } } if d.Zip { diff --git a/main_test.go b/main_test.go index 46486a9..da2b02f 100644 --- a/main_test.go +++ b/main_test.go @@ -2,7 +2,6 @@ package main import ( "os" - "path/filepath" "strings" "testing" ) @@ -40,7 +39,6 @@ func TestTildeReplacement_TildeDir(t *testing.T) { func TestReadConfigFile_InheritsPushConfigsAndExpandsHome(t *testing.T) { t.Parallel() - configPath := filepath.Join(t.TempDir(), "conf.yml") config := `destination: local: - path: "~/primary" @@ -54,10 +52,15 @@ destination: local: - path: "/tmp/secondary" ` - - if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil { + f, err := os.CreateTemp(t.TempDir(), "gickup-test-*.yml") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + configPath := f.Name() + if _, err := f.WriteString(config); err != nil { t.Fatalf("write config: %v", err) } + f.Close() confs := readConfigFile(configPath) if len(confs) != 2 { @@ -80,3 +83,149 @@ destination: t.Fatal("expected initial push config to be populated") } } + +func TestReadConfigFile_S3UseStaticCredsTrue(t *testing.T) { + t.Parallel() + + config := `destination: + s3: + - bucket: "my-bucket" + endpoint: "s3.example.com" + use_static_creds: true + accesskey: "AKID" + secretkey: "SECRET" +` + f, err := os.CreateTemp(t.TempDir(), "gickup-test-*.yml") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + configPath := f.Name() + if _, err := f.WriteString(config); err != nil { + t.Fatalf("write config: %v", err) + } + f.Close() + + confs := readConfigFile(configPath) + if len(confs) != 1 { + t.Fatalf("expected 1 config, got %d", len(confs)) + } + + s3 := confs[0].Destination.S3 + if len(s3) != 1 { + t.Fatalf("expected 1 S3 destination, got %d", len(s3)) + } + + if !s3[0].UseStaticCreds { + t.Fatal("expected UseStaticCreds to be true") + } +} + +func TestReadConfigFile_S3UseStaticCredsFalse(t *testing.T) { + t.Parallel() + + config := `destination: + s3: + - bucket: "my-bucket" + endpoint: "s3.example.com" + use_static_creds: false +` + f, err := os.CreateTemp(t.TempDir(), "gickup-test-*.yml") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + configPath := f.Name() + if _, err := f.WriteString(config); err != nil { + t.Fatalf("write config: %v", err) + } + f.Close() + + confs := readConfigFile(configPath) + if len(confs) != 1 { + t.Fatalf("expected 1 config, got %d", len(confs)) + } + + s3 := confs[0].Destination.S3 + if len(s3) != 1 { + t.Fatalf("expected 1 S3 destination, got %d", len(s3)) + } + + if s3[0].UseStaticCreds { + t.Fatal("expected UseStaticCreds to be false") + } +} + +func TestReadConfigFile_S3UseStaticCredsResolvesEnvVars(t *testing.T) { + t.Setenv("TEST_S3_ACCESS", "resolved-access") + t.Setenv("TEST_S3_SECRET", "resolved-secret") + + config := `destination: + s3: + - bucket: "my-bucket" + endpoint: "s3.example.com" + use_static_creds: true + accesskey: "TEST_S3_ACCESS" + secretkey: "TEST_S3_SECRET" +` + f, err := os.CreateTemp(t.TempDir(), "gickup-test-*.yml") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + configPath := f.Name() + if _, err := f.WriteString(config); err != nil { + t.Fatalf("write config: %v", err) + } + f.Close() + + confs := readConfigFile(configPath) + s3 := confs[0].Destination.S3[0] + + // Simulate the key resolution that backup() does when UseStaticCreds is true + if !s3.UseStaticCreds { + t.Fatal("expected UseStaticCreds to be true") + } + + accessKey, err := s3.GetKey(s3.AccessKey) + if err != nil { + t.Fatalf("GetKey(accesskey) error = %v", err) + } + + secretKey, err := s3.GetKey(s3.SecretKey) + if err != nil { + t.Fatalf("GetKey(secretkey) error = %v", err) + } + + if accessKey != "resolved-access" { + t.Fatalf("accesskey = %q, want resolved-access", accessKey) + } + + if secretKey != "resolved-secret" { + t.Fatalf("secretkey = %q, want resolved-secret", secretKey) + } +} + +func TestReadConfigFile_S3UseStaticCredsAbsentSkipsKeyResolution(t *testing.T) { + t.Parallel() + + config := `destination: + s3: + - bucket: "my-bucket" + endpoint: "s3.example.com" +` + f, err := os.CreateTemp(t.TempDir(), "gickup-test-*.yml") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + configPath := f.Name() + if _, err := f.WriteString(config); err != nil { + t.Fatalf("write config: %v", err) + } + f.Close() + + confs := readConfigFile(configPath) + s3 := confs[0].Destination.S3[0] + + // When UseStaticCreds is false (zero value), backup() skips key resolution entirely + if s3.UseStaticCreds { + t.Fatal("expected UseStaticCreds to be false when absent from config") + } +} diff --git a/s3/s3.go b/s3/s3.go index 73689fd..b2ae944 100644 --- a/s3/s3.go +++ b/s3/s3.go @@ -14,11 +14,19 @@ import ( var sub zerolog.Logger +// getCredentials returns the appropriate credentials based on the S3Repo configuration +func getCredentials(s3repo types.S3Repo) *credentials.Credentials { + if s3repo.UseStaticCreds { + return credentials.NewStaticV4(s3repo.AccessKey, s3repo.SecretKey, s3repo.Token) + } + return credentials.NewIAM("") +} + // UploadDirToS3 uploads the contents of a directory to S3-compatible storage func UploadDirToS3(directory string, s3repo types.S3Repo, options *minio.PutObjectOptions) error { // Initialize minio client object. client, err := minio.New(s3repo.Endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(s3repo.AccessKey, s3repo.SecretKey, s3repo.Token), + Creds: getCredentials(s3repo), Secure: s3repo.UseSSL, Region: s3repo.Region, }) @@ -71,7 +79,7 @@ func DeleteObjectsNotInRepo(directory, bucketdir string, s3repo types.S3Repo) er sub = logger.CreateSubLogger("stage", "s3", "endpoint", s3repo.Endpoint, "bucket", s3repo.Bucket) // Initialize minio client object. client, err := minio.New(s3repo.Endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(s3repo.AccessKey, s3repo.SecretKey, s3repo.Token), + Creds: getCredentials(s3repo), Secure: s3repo.UseSSL, Region: s3repo.Region, }) diff --git a/s3/s3_test.go b/s3/s3_test.go new file mode 100644 index 0000000..d52b5a4 --- /dev/null +++ b/s3/s3_test.go @@ -0,0 +1,49 @@ +package s3 + +import ( + "testing" + + "github.com/cooperspencer/gickup/types" +) + +func TestGetCredentialsStaticCredsReturnsStaticValues(t *testing.T) { + t.Parallel() + + s3repo := types.S3Repo{ + UseStaticCreds: true, + AccessKey: "test-access", + SecretKey: "test-secret", + Token: "test-token", + } + + creds := getCredentials(s3repo) + if creds == nil { + t.Fatal("expected non-nil credentials") + } + + val, err := creds.Get() + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if val.AccessKeyID != "test-access" { + t.Fatalf("AccessKeyID = %q, want %q", val.AccessKeyID, "test-access") + } + + if val.SecretAccessKey != "test-secret" { + t.Fatalf("SecretAccessKey = %q, want %q", val.SecretAccessKey, "test-secret") + } + + if val.SessionToken != "test-token" { + t.Fatalf("SessionToken = %q, want %q", val.SessionToken, "test-token") + } +} + +func TestGetCredentialsIAMReturnsNonNil(t *testing.T) { + t.Parallel() + + creds := getCredentials(types.S3Repo{UseStaticCreds: false}) + if creds == nil { + t.Fatal("expected non-nil IAM credentials") + } +} diff --git a/types/types.go b/types/types.go index 31ad9c9..a4f126c 100644 --- a/types/types.go +++ b/types/types.go @@ -552,6 +552,7 @@ func StatRemote(remoteURL, sshURL string, repo GenRepo) bool { type S3Repo struct { Bucket string `yaml:"bucket"` Endpoint string `yaml:"endpoint"` + UseStaticCreds bool `yaml:"use_static_creds" default:"true"` AccessKey string `yaml:"accesskey"` SecretKey string `yaml:"secretkey"` Token string `yaml:"token"` diff --git a/types/types_test.go b/types/types_test.go index d70f7a0..9060a6a 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -308,3 +308,21 @@ func TestResolveTrimsNothingFromLiteral(t *testing.T) { t.Fatalf("resolve() = %q, want already-set", got) } } + +func TestS3RepoUseStaticCredsDefaultFalse(t *testing.T) { + t.Parallel() + + var s3 S3Repo + if s3.UseStaticCreds { + t.Fatal("expected UseStaticCreds to be false by default (Go zero value)") + } +} + +func TestS3RepoUseStaticCredsExplicitTrue(t *testing.T) { + t.Parallel() + + s3 := S3Repo{UseStaticCreds: true} + if !s3.UseStaticCreds { + t.Fatal("expected UseStaticCreds to be true when explicitly set") + } +}