mirror of
https://github.com/cooperspencer/gickup.git
synced 2026-10-19 16:16:46 +00:00
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:
@@ -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:"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user