feat: Add support for S3 AWS Credentials Chain (#394)

* feat: Add support for S3 static credentials configuration

- Updated S3Repo struct to include UseStaticCreds field
- Refactored credential retrieval logic in UploadDirToS3 and DeleteObjectsNotInRepo functions
- Added tests for S3Repo and credential retrieval functions

* docs: add changes for use_static_creds

* fix: lint issues

* fix: exclude gosec sanitized file path
This commit is contained in:
Bernat Mut
2026-06-02 21:10:28 +02:00
committed by GitHub
parent ee95e89de5
commit fa7816143d
9 changed files with 252 additions and 18 deletions
+2
View File
@@ -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:"
+1
View File
@@ -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
+4
View File
@@ -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"
+14 -12
View File
@@ -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 {
+153 -4
View File
@@ -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")
}
}
+10 -2
View File
@@ -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,
})
+49
View File
@@ -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")
}
}
+1
View File
@@ -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"`
+18
View File
@@ -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")
}
}